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
@@ -2,7 +2,15 @@
2
2
 
3
3
  require "json"
4
4
  require "etc"
5
- require "ace/assign"
5
+ require "ace/assign/atoms/evidence_digest"
6
+ require_relative "../authorization_path"
7
+ require_relative "../atoms/service_input"
8
+ require "ace/git/atoms/service_pr_input"
9
+ require_relative "grant_resolver"
10
+ require_relative "caller_authorizer"
11
+ require_relative "service_policy"
12
+ require_relative "../atoms/protected_workspace_prune_input"
13
+ require_relative "../atoms/protected_publication_input"
6
14
 
7
15
  module Ace
8
16
  module Lab
@@ -11,18 +19,28 @@ module Ace
11
19
  # Only composition supplies collaborators; no wire-selected policy,
12
20
  # executable, input path or caller identity is accepted here.
13
21
  class ProtectedServicePolicy
14
- def initialize(proposal_resolver:, document_loader: nil)
22
+ def initialize(proposal_resolver:, document_loader: nil, cleanup_owner: nil)
15
23
  @proposal_resolver = proposal_resolver
24
+ @cleanup_owner = cleanup_owner
16
25
  @document_loader = document_loader || -> { GrantResolver.trusted_document(Ace::Lab.authorization_path) }
17
26
  end
18
27
 
19
- def input_binding(bytes, expected_digest:, expected_target:)
28
+ def input_binding(bytes, expected_digest:, expected_target:, operation:)
20
29
  unless bytes.is_a?(String) && bytes.bytesize.between?(1, Atoms::ServiceInput::MAX_BYTES)
21
30
  raise ArgumentError, "structured service input exceeds its fixed limit"
22
31
  end
23
- input = JSON.parse(bytes)
32
+ input = if operation == "prune-preserved-workspace"
33
+ Atoms::ProtectedWorkspacePruneInput.parse(bytes)
34
+ elsif operation == "publish"
35
+ Atoms::ProtectedPublicationInput.decode!(bytes)
36
+ else
37
+ JSON.parse(bytes)
38
+ end
24
39
  raise ArgumentError, "structured service input must be an object" unless input.is_a?(Hash)
25
40
  Atoms::ServiceInput.validate!(input)
41
+ if Ace::Git::Atoms::ServicePrInput::OPERATIONS.include?(operation)
42
+ Ace::Git::Atoms::ServicePrInput.validate(input, operation: operation)
43
+ end
26
44
  digest = Atoms::ServiceInput.digest(input)
27
45
  target = Atoms::ServiceInput.target(input)
28
46
  unless digest == expected_digest && target == expected_target
@@ -34,12 +52,26 @@ module Ace
34
52
  end
35
53
 
36
54
  def prepare!(binding, input_bytes:)
37
- input = input_binding(input_bytes, expected_digest: binding.fetch("input_digest"), expected_target: binding.fetch("target"))
55
+ input = input_binding(input_bytes, expected_digest: binding.fetch("input_digest"),
56
+ expected_target: binding.fetch("target"), operation: binding.fetch("operation"))
57
+ if binding["operation"] == "publish" && input.fetch(:input).dig("publication", "head") != binding.fetch("candidate_head")
58
+ raise SecurityError, "publication input head differs from admitted candidate"
59
+ end
38
60
  authorized = authorize!(binding)
39
61
  canonical = snapshot(binding).merge("executor_uid" => authorized.fetch(:operation).fetch("executor_uid"), "transport" => "unix")
40
62
  deep_freeze(authorized.merge(binding: canonical, input: input.fetch(:input)))
41
63
  end
42
64
 
65
+ # Selected by installed composition, independently of receiver fields.
66
+ # Ordinary services never acquire this domain-specific root capability.
67
+ def dispatch_owner_binding!(binding)
68
+ return nil unless binding.fetch("operation") == "prune-preserved-workspace"
69
+ unless @cleanup_owner
70
+ raise SecurityError, "fixed cleanup owner composition is unavailable"
71
+ end
72
+ @cleanup_owner.identity!
73
+ end
74
+
43
75
  # Called again inside each claim/begin CAS retry using the immutable
44
76
  # binding and ephemeral parsed input, never a caller filesystem path.
45
77
  def authorize!(binding)
@@ -67,6 +99,18 @@ module Ace
67
99
  operation_digest: Ace::Assign::Atoms::EvidenceDigest.digest(operation))
68
100
  end
69
101
 
102
+ # Read-only selection of the installed named cleanup receiver; no
103
+ # proposal authorization or effect capability is minted by preview.
104
+ def workspace_prune_receiver!(project:, uid:, service_id:, executor_uid:)
105
+ document = visible_document!(project: project, uid: uid)
106
+ policy = ServicePolicy.new(document, ->(*) { raise SecurityError, "preview cannot resolve proposal grants" })
107
+ operation = policy.operation!("prune-preserved-workspace", project: project, service_id: service_id)
108
+ unless operation.fetch("executor_uid") == executor_uid && executor_uid.is_a?(Integer) && executor_uid.positive? && executor_uid != uid
109
+ raise SecurityError, "preview receiver differs from the installed cleanup operation"
110
+ end
111
+ true
112
+ end
113
+
70
114
  def visible!(project:, uid:)
71
115
  visible_document!(project: project, uid: uid)
72
116
  true
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
4
+ require "ace/assign/errors"
3
5
  require "pathname"
4
6
  require "time"
5
7
 
@@ -99,6 +101,30 @@ module Ace
99
101
  decision
100
102
  end
101
103
 
104
+ # Inspection never renews effect permission or selects effect argv.
105
+ def inspection!(name, project:, service_id:, executor_uid:)
106
+ raise ArgumentError, "invalid inspection operation" unless name.is_a?(String) && name.match?(NAME)
107
+ operation = @operations[name]
108
+ unless operation.is_a?(Hash) && operation["project"] == project && operation["service_id"] == service_id &&
109
+ operation["executor_uid"].is_a?(Integer) && operation["executor_uid"] == executor_uid &&
110
+ operation.fetch("transport", "local") == "local"
111
+ raise SecurityError, "inspection does not match the selected original executor"
112
+ end
113
+ # This source-composed inspector uses the fixed original root SDK;
114
+ # configured handler argv cannot select or replace that owner.
115
+ if name == "prune-preserved-workspace"
116
+ return {"executor_uid" => executor_uid, "operation" => name}
117
+ end
118
+ argv = operation["no_effect_argv"]
119
+ unless argv.is_a?(Array) && argv.length.between?(1, 16) &&
120
+ argv.all? { |value| value.is_a?(String) && value.bytesize.between?(1, 1024) && !value.include?("\0") } &&
121
+ argv.first.start_with?("/")
122
+ raise Ace::Lab::InvalidConfigurationError, "operation has no fixed no-effect inspector"
123
+ end
124
+ executable = trusted_executable!(argv.first, executor_uid)
125
+ {"executor_uid" => executor_uid, "argv" => [executable] + argv.drop(1)}
126
+ end
127
+
102
128
  private
103
129
 
104
130
  # Validate the executable and every directory component on its
@@ -5,6 +5,10 @@ require "ace/assign/authority/router"
5
5
  require "ace/assign/authority/launch_lifecycle"
6
6
  require "ace/assign/authority/endcap"
7
7
  require_relative "../molecules/protected_service_policy"
8
+ require "ace/hitl/providers/lab"
9
+ require "ace/hitl/providers/lab/protected_assignment_binding"
10
+ require "ace/hitl/lifecycle/service_publication_binding"
11
+ require "ace/hitl/lifecycle/service"
8
12
 
9
13
  module Ace
10
14
  module Lab
@@ -13,7 +17,7 @@ module Ace
13
17
  # and placement; source selects all handlers, policy and evidence readers.
14
18
  class AuthorityComposition
15
19
  REQUIRED_ENDCAP = %w[submit_candidate export_candidate assign_review accept_review request_service
16
- begin_dispatch complete_service complete_no_effect submit_result finish recover bind_inbox reconcile_inbox
20
+ begin_dispatch publication_challenge publication_continue claim_service_settlement complete_service complete_no_effect submit_result finish recover bind_inbox
17
21
  service_status evidence_fetch].freeze
18
22
 
19
23
  def initialize(authority_id:, deployment: Ace::Assign::Authority::Deployment.load,
@@ -54,11 +58,28 @@ module Ace
54
58
  policy = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(project, reference, binding) {
55
59
  journals.fetch(project).proposal_authorize!(reference, binding)
56
60
  })
57
- launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, kernel: @kernel, journals: journals)
58
- endcap = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel, service_policy: policy)
61
+ history = Ace::Assign::Authority::DeploymentHistory.load
62
+ launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, deployment_history: history,
63
+ kernel: @kernel, journals: journals)
64
+ endcap = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel,
65
+ service_policy: policy, deployment_history: history)
59
66
  router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
60
67
  Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
61
- lifecycle: router, composition: "services")
68
+ lifecycle: router, composition: "services", hitl_service: publication_hitl(endcap, journals))
69
+ end
70
+
71
+ private
72
+
73
+ def publication_hitl(endcap, journals)
74
+ provider = Ace::Hitl::Providers::Lab
75
+ policy = provider.grants_policy(grants_path: provider::DEFAULT_GRANTS_PATH)
76
+ unless policy.service_uid == @deployment.authority(@authority_id).fetch("uid")
77
+ raise InvalidConfigurationError, "protected HITL service principal differs from installed authority"
78
+ end
79
+ ordinary = provider::ProtectedAssignmentBinding.new(authority: endcap, kernel: @kernel, journals: journals)
80
+ binding = Ace::Hitl::Lifecycle::ServicePublicationBinding.new(ordinary: ordinary, authority: endcap, kernel: @kernel)
81
+ Ace::Hitl::Lifecycle::Service.new(root: provider::DEFAULT_STORE_ROOT,
82
+ socket_path: provider::DEFAULT_SOCKET_PATH, binding: binding, policy: policy)
62
83
  end
63
84
  end
64
85
  end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_socket"
4
+ require "ace/runtime/molecules/protected_artifact_set"
5
+ require "ace/assign/authority/transfer_codec"
6
+ require_relative "../molecules/protected_cleanup_owner_admission"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Organisms
11
+ # The immutable direct root entry supplies all collaborators. Durable
12
+ # request/outcome authority stays in its original canonical journal and
13
+ # Installer's retained physical result, never this transport owner.
14
+ class ProtectedCleanupOwner
15
+ Wire = Ace::Runtime::Molecules::ProtectedSocket
16
+ Client = Molecules::ProtectedCleanupOwnerClient
17
+ MAX_RETAINED = 256
18
+ MAX_CONNECTIONS = 16
19
+
20
+ def initialize(observer:, admission:, snapshots:, journals:, kernel:, installer:, scratch_root:,
21
+ protection: Ace::Runtime::Molecules::ProtectedArtifactSet::Protection.new)
22
+ @observer, @admission, @snapshots, @journals = observer, admission, snapshots, journals
23
+ @kernel, @installer = kernel, installer
24
+ @protection = protection
25
+ @codec = Ace::Assign::Authority::TransferCodec.new(root: scratch_root)
26
+ @lock, @consumed = Mutex.new, {}
27
+ @connections, @changed = [], ConditionVariable.new
28
+ @stopping = false
29
+ end
30
+
31
+ # Only the trusted fixed entry supplies the installed connection group.
32
+ # Endpoint and lock paths remain source constants, never request inputs.
33
+ def serve(connect_gid:)
34
+ @admission.connection_group!(connect_gid)
35
+ @observer.observe_self!(deadline: Wire.deadline(5))
36
+ path = Client::PATH
37
+ parent = File.dirname(path)
38
+ Wire.root_path!(parent, directory: true)
39
+ parents = hold_parent!(parent)
40
+ parent_stat = File.lstat(parent)
41
+ unless parent_stat.directory? && parent_stat.uid == Process.uid && parent_stat.gid == connect_gid && (parent_stat.mode & 0o7777) == 0o2750
42
+ raise SecurityError, "cleanup listener parent differs"
43
+ end
44
+ parent_identity = [parent_stat.dev, parent_stat.ino]
45
+ lifetime = File.open("#{path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
46
+ lifetime.close_on_exec = true
47
+ stat = lifetime.stat
48
+ unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
49
+ raise SecurityError, "cleanup lifetime lock differs"
50
+ end
51
+ @protection.verify!("#{path}.lock", lifetime, directory: false)
52
+ raise SecurityError, "cleanup lifetime lock path changed" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
53
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup listener already active" unless lifetime.flock(File::LOCK_EX | File::LOCK_NB)
54
+ raise SecurityError, "cleanup lifetime lock path changed after flock" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
55
+ verify_parents!(parents)
56
+ begin
57
+ stale = File.lstat(path)
58
+ unless stale.socket? && stale.uid == Process.uid && stale.gid == connect_gid && (stale.mode & 0o7777) == 0o660
59
+ raise SecurityError, "cleanup stale endpoint differs"
60
+ end
61
+ File.unlink(path)
62
+ rescue Errno::ENOENT
63
+ nil
64
+ end
65
+ @listener = UNIXServer.new(path)
66
+ @listener.close_on_exec = true
67
+ created = File.lstat(path)
68
+ unless created.socket? && created.uid == Process.uid && created.gid == connect_gid &&
69
+ [File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
70
+ raise SecurityError, "cleanup endpoint creation differs"
71
+ end
72
+ endpoint = [created.dev, created.ino, created.uid, created.gid]
73
+ File.chmod(0o660, path)
74
+ own = File.lstat(path)
75
+ unless own.socket? && (own.mode & 0o7777) == 0o660 && [own.dev, own.ino, own.uid, own.gid] == endpoint && [File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
76
+ raise SecurityError, "cleanup endpoint publication differs"
77
+ end
78
+ published = true
79
+ until @stopping
80
+ socket = @listener.accept
81
+ accepted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ socket.close_on_exec = true
83
+ admitted = @lock.synchronize do
84
+ @connections.reject! { |thread| !thread.alive? }
85
+ if @connections.size >= MAX_CONNECTIONS
86
+ false
87
+ else
88
+ worker = Thread.new(socket, accepted_at) do |owned_socket, original_acceptance|
89
+ begin
90
+ verify_parents!(parents)
91
+ current = File.lstat(path)
92
+ unless current.socket? && (current.mode & 0o7777) == 0o660 && [current.dev, current.ino, current.uid, current.gid] == endpoint
93
+ raise SecurityError, "cleanup endpoint changed"
94
+ end
95
+ handle(owned_socket, accepted_at: original_acceptance)
96
+ rescue SecurityError, Ace::Runtime::RuntimeUnavailableError, Ace::Assign::Error, IOError, SystemCallError => error
97
+ # Refusal/transport failure closes only this connection.
98
+ # Its consumed state, including uncertainty, is preserved.
99
+ code = error.is_a?(SecurityError) ? "unauthorized_or_changed_selection" : "unavailable"
100
+ warn("cleanup connection refused: #{code}")
101
+ ensure
102
+ owned_socket.close unless owned_socket.closed?
103
+ @lock.synchronize { @changed.broadcast }
104
+ end
105
+ end
106
+ @connections << worker
107
+ true
108
+ end
109
+ end
110
+ socket.close unless admitted
111
+ end
112
+ rescue IOError, Errno::EBADF
113
+ raise unless @stopping
114
+ ensure
115
+ @listener.close if @listener && !@listener.closed?
116
+ @lock.synchronize do
117
+ # Wait for actual callbacks, never mistake a dead callback's
118
+ # unconfirmed outcome for activity or physical settlement.
119
+ while @connections.any?(&:alive?) || @consumed.values.any? { |entry| entry[:state] == :invoked }
120
+ @changed.wait(@lock, 0.1)
121
+ end
122
+ end
123
+ unconfirmed = @lock.synchronize { @consumed.values.any? { |entry| entry[:state] == :unconfirmed } }
124
+ if endpoint
125
+ begin
126
+ verify_parents!(parents)
127
+ current = File.lstat(path)
128
+ File.unlink(path) if current.socket? && (!published || (current.mode & 0o7777) == 0o660) && [current.dev, current.ino, current.uid, current.gid] == endpoint
129
+ rescue Errno::ENOENT
130
+ nil
131
+ end
132
+ end
133
+ lifetime&.close
134
+ parents&.each { |entry| entry.fetch(:handle).close }
135
+ if unconfirmed && $!.nil?
136
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup invocation outcome remains unconfirmed after callback termination"
137
+ end
138
+ end
139
+
140
+ def stop
141
+ @stopping = true
142
+ @listener.close if @listener && !@listener.closed?
143
+ end
144
+
145
+ # Listener construction and fixed socket ownership are separately
146
+ # provisioned; every accepted connection still authenticates its peer.
147
+ def handle(socket, accepted_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
148
+ admission_deadline = accepted_at + 5
149
+ peer = @kernel.peer(socket)
150
+ frame = Wire.read(Wire::Ingress.new(socket), deadline: admission_deadline, limit: 65_536)
151
+ eof!(socket, admission_deadline)
152
+ original = @observer.observe_self!(deadline: admission_deadline)
153
+ if frame == {"schema" => Client::SCHEMA, "kind" => "identity"}
154
+ @admission.identity_reader!(peer)
155
+ Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "identity", "operation_owner_binding" => original},
156
+ deadline: admission_deadline, limit: Client::LIMIT)
157
+ return
158
+ end
159
+ @admission.receiver!(peer)
160
+ receiver_peer = Atoms::ProtectedWorkspacePruneInput.freeze_value(JSON.parse(JSON.generate(peer)))
161
+ deadline = frame["kind"] == "preview" ? admission_deadline : accepted_at + 300
162
+ context = nil
163
+ @snapshots.call do |snapshot|
164
+ # Reuse exact immutable event bytes only during this held read.
165
+ # Peer admission remains fresh; neither the view nor its memo
166
+ # survives into Installer effects or the next connection.
167
+ Ace::Assign::Molecules::EvidenceJournal.with_event_read_operation do
168
+ snapshot.with(deadline: deadline) do |view|
169
+ context = if frame["kind"] == "preview"
170
+ @admission.preview!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
171
+ elsif frame["kind"] == "inspect"
172
+ @admission.inspect!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
173
+ else
174
+ @admission.admit!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
175
+ end
176
+ end
177
+ end
178
+ end
179
+ if frame["kind"] == "preview"
180
+ unless @installer.respond_to?(:preview_cleanup!)
181
+ raise SecurityError, "same Installer preview producer unavailable"
182
+ end
183
+ result = @installer.preview_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
184
+ unless Ace::Assign::Atoms::EvidenceDigest.digest(@observer.observe_self!(deadline: deadline)) ==
185
+ Ace::Assign::Atoms::EvidenceDigest.digest(original)
186
+ raise SecurityError, "cleanup original owner changed during preview"
187
+ end
188
+ result = Ace::Lab::Atoms::ProtectedWorkspacePrunePreview.result!(result,
189
+ intent: frame.fetch("intent"), context: frame.fetch("maintenance_context"))
190
+ Wire.write(socket, result, deadline: deadline, limit: Client::LIMIT)
191
+ return
192
+ end
193
+ if frame["kind"] == "inspect"
194
+ inspection(socket, frame, context, original, deadline, receiver_peer: receiver_peer)
195
+ return
196
+ end
197
+ key = Ace::Assign::Atoms::EvidenceDigest.digest(frame)
198
+ retained = @lock.synchronize do
199
+ if (entry = @consumed[context.fetch("record").fetch("request_id")])
200
+ raise SecurityError, "cleanup original invocation input differs" unless entry.fetch(:key) == key
201
+ entry
202
+ else
203
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
204
+ @consumed[context.fetch("record").fetch("request_id")] = {key: key, state: :invoking}
205
+ end
206
+ end
207
+ result = @lock.synchronize { retained[:result] }
208
+ unless result
209
+ authorized = @lock.synchronize do
210
+ next false unless retained[:state] == :invoking && !retained[:inhibited]
211
+ retained[:state] = :invoked
212
+ true
213
+ end
214
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup original result unavailable" unless authorized
215
+ begin
216
+ # No canonical/source lock is held over fixed privileged work.
217
+ unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
218
+ raise SecurityError, "cleanup original owner changed before invocation"
219
+ end
220
+ result = @installer.execute_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
221
+ result = result!(result, frame)
222
+ @lock.synchronize { retained[:result] = result; retained[:state] = :completed }
223
+ ensure
224
+ @lock.synchronize do
225
+ if retained[:state] == :invoked
226
+ retained[:state] = :unconfirmed
227
+ retained[:inhibited] = true
228
+ end
229
+ @changed.broadcast
230
+ end
231
+ end
232
+ end
233
+ unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
234
+ raise SecurityError, "cleanup original owner changed before result"
235
+ end
236
+ ref = result.fetch(:receipt_ref)
237
+ Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "result", "request_id" => frame.fetch("request_id"),
238
+ "input_digest" => frame.fetch("input_digest"), "receipt_ref" => ref}, deadline: deadline, limit: Client::LIMIT)
239
+ descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
240
+ @codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
241
+ ensure
242
+ socket.close unless socket.closed?
243
+ end
244
+
245
+ private
246
+
247
+ def inspection(socket, frame, context, original, deadline, receiver_peer:)
248
+ original_owner = Ace::Assign::Atoms::EvidenceDigest.digest(context.fetch("record").fetch("operation_owner_binding")) ==
249
+ Ace::Assign::Atoms::EvidenceDigest.digest(original)
250
+ if original_owner
251
+ executable = frame.except("challenge_ref").merge("kind" => "execute")
252
+ key = Ace::Assign::Atoms::EvidenceDigest.digest(executable)
253
+ @lock.synchronize do
254
+ request = context.fetch("record").fetch("request_id")
255
+ retained = @consumed[request]
256
+ if retained
257
+ raise SecurityError, "cleanup original inspection input differs" unless retained.fetch(:key) == key
258
+ else
259
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
260
+ retained = @consumed[request] = {key: key, state: :inhibited}
261
+ end
262
+ retained[:inhibited] = true
263
+ retained[:state] = :inhibited if retained[:state] == :invoking
264
+ while retained[:state] == :invoked
265
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
266
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup original effect remains unconfirmed" unless remaining.positive?
267
+ @changed.wait(@lock, remaining)
268
+ end
269
+ end
270
+ # This is callback inhibition only. Child/writer/physical absence
271
+ # still requires the actual domain inspector's independent proof.
272
+ inhibition = {"operation_owner_binding_digest" => frame.fetch("operation_owner_binding_digest"),
273
+ "dispatch_event_digest" => frame.fetch("dispatch_event_digest"), "state" => "inhibited", "pending_effects" => 0}
274
+ context = Atoms::ProtectedWorkspacePruneInput.freeze_value(context.merge("input_inhibition" => inhibition))
275
+ end
276
+ unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
277
+ raise SecurityError, "cleanup readback owner changed before inspection"
278
+ end
279
+ inspected = @installer.inspect_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
280
+ unless inspected.is_a?(Hash) && inspected.keys.sort == %i[bytes kind receipt_ref] &&
281
+ %w[succeeded failed-no-effect].include?(inspected[:kind])
282
+ raise SecurityError, "cleanup inspection result unavailable"
283
+ end
284
+ kind = inspected.fetch(:kind).dup.freeze
285
+ unless original_owner || kind == "succeeded"
286
+ raise Ace::Runtime::RuntimeUnavailableError, "original cleanup lifetime exclusion unavailable"
287
+ end
288
+ result = result!(inspected.except(:kind), frame)
289
+ unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
290
+ raise SecurityError, "cleanup readback owner changed before inspection result"
291
+ end
292
+ success = kind == "succeeded"
293
+ Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => success ? "completed-result" : "inspection",
294
+ "request_id" => frame.fetch("request_id"), "input_digest" => frame.fetch("input_digest"),
295
+ (success ? "receipt_ref" : "inspection_ref") => result.fetch(:receipt_ref)}, deadline: deadline, limit: Client::LIMIT)
296
+ descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
297
+ @codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
298
+ end
299
+
300
+ def hold_parent!(parent)
301
+ entries = []
302
+ paths = []
303
+ cursor = parent
304
+ loop do
305
+ paths << cursor
306
+ break if cursor == "/"
307
+ cursor = File.dirname(cursor)
308
+ end
309
+ paths.reverse_each do |path|
310
+ handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
311
+ handle.close_on_exec = true
312
+ entries << {path: path, handle: handle, identity: file_identity(handle.stat)}
313
+ @protection.verify!(path, handle, directory: true)
314
+ raise SecurityError, "cleanup parent changed while pinned" unless file_identity(File.lstat(path)) == entries.last.fetch(:identity)
315
+ end
316
+ complete = true
317
+ entries
318
+ ensure
319
+ entries&.each { |entry| entry.fetch(:handle).close } unless complete
320
+ end
321
+
322
+ def verify_parents!(entries)
323
+ entries.each do |entry|
324
+ unless file_identity(entry.fetch(:handle).stat) == entry.fetch(:identity) &&
325
+ file_identity(File.lstat(entry.fetch(:path))) == entry.fetch(:identity)
326
+ raise SecurityError, "cleanup protected ancestry changed"
327
+ end
328
+ end
329
+ end
330
+
331
+ def file_identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
332
+
333
+ def result!(result, frame)
334
+ unless result.is_a?(Hash) && result.keys.sort == %i[bytes receipt_ref] && result.fetch(:bytes).is_a?(String)
335
+ raise SecurityError, "cleanup fixed result unavailable"
336
+ end
337
+ ref = result.fetch(:receipt_ref)
338
+ Atoms::ProtectedWorkspacePruneInput.reference!(ref)
339
+ bytes = result.fetch(:bytes)
340
+ unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
341
+ raise SecurityError, "cleanup fixed result bytes differ"
342
+ end
343
+ result = {receipt_ref: JSON.parse(JSON.generate(ref)), bytes: bytes.dup}
344
+ Atoms::ProtectedWorkspacePruneInput.freeze_value(result)
345
+ end
346
+
347
+ def eof!(socket, deadline)
348
+ guarded = Wire::Ingress.new(socket)
349
+ loop do
350
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
351
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup request deadline expired" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
352
+ value = guarded.read_nonblock(1, exception: false)
353
+ next if value == :wait_readable
354
+ raise SecurityError, "cleanup request contains trailing bytes" unless value.nil?
355
+ return
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end