ace-assign 0.63.1 → 0.66.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/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +62 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +282 -0
- data/docs/usage.md +205 -3
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +257 -0
- data/lib/ace/assign/authority/deployment.rb +627 -0
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +455 -0
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +398 -0
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +598 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +393 -0
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +59 -0
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +76 -0
- data/lib/ace/assign/authority/server.rb +300 -0
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +286 -0
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +187 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +11 -1
- data/lib/ace/assign/cli/commands/delivery.rb +107 -0
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +38 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +56 -4
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +4 -2
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +4 -102
- metadata +186 -8
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "deployment"
|
|
5
|
+
require_relative "transfer_codec"
|
|
6
|
+
require_relative "../molecules/evidence_journal"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Assign
|
|
10
|
+
module Authority
|
|
11
|
+
# Existing assignment owner transport; kernel peers select roles, never argv.
|
|
12
|
+
class Server
|
|
13
|
+
CONNECTIONS = 16
|
|
14
|
+
def initialize(authority_id:, lifecycle:, deployment: Deployment.load,
|
|
15
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch", hitl_service: nil)
|
|
16
|
+
@authority_id, @lifecycle, @deployment, @kernel = authority_id, lifecycle, deployment, kernel
|
|
17
|
+
@composition = composition
|
|
18
|
+
@hitl_service = hitl_service
|
|
19
|
+
deployment.verify_composition!(authority_id, composition: composition)
|
|
20
|
+
@service = deployment.authority(authority_id)
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@connections = []
|
|
23
|
+
@handlers = []
|
|
24
|
+
@transfers = 0
|
|
25
|
+
@stopped = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def serve
|
|
29
|
+
@kernel.supported!
|
|
30
|
+
me = @kernel.capture(Process.pid)
|
|
31
|
+
unless principal?(me, @service, "uid", "gid", "groups")
|
|
32
|
+
raise AttemptErrors::UnauthorizedIdentity, "listener principal differs from installed authority"
|
|
33
|
+
end
|
|
34
|
+
@deployment.verify_receiver_paths!(@authority_id)
|
|
35
|
+
directory = File.dirname(@service.fetch("socket_path"))
|
|
36
|
+
wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
|
|
37
|
+
lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
38
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
39
|
+
raise AttemptErrors::Conflict, "assignment authority already owns its listener"
|
|
40
|
+
end
|
|
41
|
+
start_hitl_service!
|
|
42
|
+
path = @service.fetch("socket_path")
|
|
43
|
+
if File.exist?(path) || File.symlink?(path)
|
|
44
|
+
raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
|
|
45
|
+
end
|
|
46
|
+
@listener = UNIXServer.new(path)
|
|
47
|
+
File.chmod(0o660, path)
|
|
48
|
+
@endpoint = wire.socket_identity(path)
|
|
49
|
+
until @stopped
|
|
50
|
+
if @hitl_thread && !@hitl_thread.alive?
|
|
51
|
+
raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary stopped before its authority"
|
|
52
|
+
end
|
|
53
|
+
ready = IO.select([@listener], nil, nil, 0.25)
|
|
54
|
+
next unless ready
|
|
55
|
+
socket = @listener.accept_nonblock(exception: false)
|
|
56
|
+
next if socket == :wait_readable
|
|
57
|
+
admitted = @mutex.synchronize do
|
|
58
|
+
if @connections.size < CONNECTIONS
|
|
59
|
+
@connections << socket
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
unless admitted
|
|
64
|
+
refusal(socket, "busy")
|
|
65
|
+
socket.close
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
@handlers.reject! { |handler| !handler.alive? }
|
|
70
|
+
@handlers << Thread.new(socket) { |connection| receive(connection) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
rescue IOError, Errno::EBADF
|
|
74
|
+
raise unless @stopped
|
|
75
|
+
ensure
|
|
76
|
+
stop
|
|
77
|
+
stop_hitl_service!
|
|
78
|
+
# Closing a stream does not cancel a handler already inside dispatch.
|
|
79
|
+
# The owner lock and endpoint remain held until every handler ends.
|
|
80
|
+
@mutex.synchronize { @handlers.dup }.each(&:join)
|
|
81
|
+
@lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
|
|
82
|
+
if @endpoint && same_endpoint?(path)
|
|
83
|
+
File.unlink(path)
|
|
84
|
+
end
|
|
85
|
+
lock&.close
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def request_stop
|
|
89
|
+
@stopped = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def stop
|
|
93
|
+
@stopped = true
|
|
94
|
+
@listener&.close unless @listener&.closed?
|
|
95
|
+
@mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# The existing HITL boundary is a sibling owned by this listener's
|
|
101
|
+
# lifetime. It must be ready before assignment ingress is opened.
|
|
102
|
+
def start_hitl_service!
|
|
103
|
+
return unless @hitl_service
|
|
104
|
+
ready = Queue.new
|
|
105
|
+
@hitl_thread = Thread.new do
|
|
106
|
+
@hitl_service.run(on_ready: -> { ready << :ready })
|
|
107
|
+
rescue Exception => error
|
|
108
|
+
@hitl_failure = error
|
|
109
|
+
ready << error
|
|
110
|
+
end
|
|
111
|
+
@hitl_thread.report_on_exception = false
|
|
112
|
+
result = ready.pop(timeout: 5)
|
|
113
|
+
raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary startup timed out" if result.nil?
|
|
114
|
+
raise result if result.is_a?(Exception)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stop_hitl_service!
|
|
118
|
+
return unless @hitl_thread
|
|
119
|
+
@hitl_service.stop
|
|
120
|
+
@hitl_thread.join
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def same_endpoint?(path)
|
|
124
|
+
wire.socket_identity(path) == @endpoint
|
|
125
|
+
rescue StandardError
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def receive(socket)
|
|
130
|
+
deadline = wire.deadline(30)
|
|
131
|
+
peer = @kernel.peer(socket)
|
|
132
|
+
frame = wire.read(socket, deadline: [deadline, wire.deadline(5)].min, with_size: true)
|
|
133
|
+
request = frame.fetch(:data)
|
|
134
|
+
unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
|
|
135
|
+
request["version"].is_a?(Integer) && request["version"] == 1 && request["operation"].is_a?(String) && request["params"].is_a?(Hash)
|
|
136
|
+
raise ArgumentError, "invalid authority envelope"
|
|
137
|
+
end
|
|
138
|
+
deadline = wire.deadline(90) if %w[reserve_attempt close_execution_scope stop_attempt].include?(request.fetch("operation"))
|
|
139
|
+
params = request.fetch("params")
|
|
140
|
+
map = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
|
|
141
|
+
unless map["authority_id"] == @authority_id && map["project_id"] == request["project_id"]
|
|
142
|
+
raise AttemptErrors::UnauthorizedIdentity, "mapping belongs to another authority or project"
|
|
143
|
+
end
|
|
144
|
+
project = @deployment.project(map.fetch("project_id"))
|
|
145
|
+
owner_contexts = project.fetch("inbox_contexts", {}).select do |_id, context|
|
|
146
|
+
context.fetch("native_mapping_id") == params.fetch("mapping_id") &&
|
|
147
|
+
context.fetch("owner_credentials").values_at("uid", "gid", "groups") == peer.values_at("uid", "gid", "groups")
|
|
148
|
+
end
|
|
149
|
+
context_query = %w[inbox_context_original].include?(request["operation"])
|
|
150
|
+
if context_query || !owner_contexts.empty?
|
|
151
|
+
unless context_query && @composition == "services" && request["mutation_id"].nil? &&
|
|
152
|
+
owner_contexts.key?(params.fetch("inbox_context_id")) && frame.fetch(:bytesize) <= 16_384
|
|
153
|
+
raise AttemptErrors::UnauthorizedIdentity, "context owner is exclusive to its fixed read-only inbox queries"
|
|
154
|
+
end
|
|
155
|
+
role = :context_owner
|
|
156
|
+
end
|
|
157
|
+
if request["operation"] == "native_readiness"
|
|
158
|
+
unless @composition == "launch" && request["mutation_id"].nil? && params.keys == ["mapping_id"] &&
|
|
159
|
+
frame.fetch(:bytesize) <= 16_384
|
|
160
|
+
raise ArgumentError, "private readiness envelope differs"
|
|
161
|
+
end
|
|
162
|
+
admitted_transfer = @mutex.synchronize do
|
|
163
|
+
if @transfers < 2
|
|
164
|
+
@transfers += 1
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
raise AttemptErrors::Conflict, "readiness transfer capacity is busy" unless admitted_transfer
|
|
169
|
+
@lifecycle.native_readiness!(mapping_id: params.fetch("mapping_id"), peer: peer, socket: socket,
|
|
170
|
+
codec: transfer_codec, deadline: wire.deadline(10))
|
|
171
|
+
return
|
|
172
|
+
end
|
|
173
|
+
role ||= if principal?(peer, map, "launcher_uid", "launcher_gid", "launcher_groups")
|
|
174
|
+
:launcher
|
|
175
|
+
elsif principal?(peer, map, "worker_uid", "worker_gid", "worker_groups")
|
|
176
|
+
:worker
|
|
177
|
+
end
|
|
178
|
+
unless role
|
|
179
|
+
project = @deployment.project(map.fetch("project_id"))
|
|
180
|
+
configured = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
|
|
181
|
+
if configured && peer["gid"] == configured["gid"] && peer["groups"] == configured["groups"]
|
|
182
|
+
{reviewer: "reviewer_uids", executor: "service_executor_uids", supervisor: "supervisor_uids"}.each do |candidate, key|
|
|
183
|
+
role ||= candidate if project.fetch(key).include?(peer.fetch("uid"))
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
raise AttemptErrors::UnauthorizedIdentity, "unmapped kernel peer" unless role
|
|
188
|
+
if request["operation"] == "launch_control"
|
|
189
|
+
raise AttemptErrors::UnauthorizedIdentity, "Private launcher control requires launcher peer" unless role == :launcher
|
|
190
|
+
raise ArgumentError, "Private launcher header is oversized" unless frame.fetch(:bytesize) <= 16_384
|
|
191
|
+
@lifecycle.serve_launch_control!(request: request, peer: peer, socket: socket, codec: transfer_codec, deadline: deadline)
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
if %w[bind_inbox finish recover request_review review_status launch_review_intent cancel_review evidence_fetch assignment_inventory observe_execution_scope close_execution_scope stop_attempt prompt_status launch_input_inhibit_selection launch_input_inhibit_completion launch_prompt_intent launch_prompt_completion claim_service_settlement workspace_prune_preview_context].include?(request["operation"]) ||
|
|
195
|
+
@composition == "services" && %w[attempt_status inbox_context_original].include?(request["operation"])
|
|
196
|
+
raise ArgumentError, "review control header is oversized" if %w[request_review review_status cancel_review launch_review_intent].include?(request["operation"]) && frame.fetch(:bytesize) > 16_384
|
|
197
|
+
bodyless_read!(socket, deadline)
|
|
198
|
+
end
|
|
199
|
+
if request["operation"] == "gate_ready"
|
|
200
|
+
raise AttemptErrors::UnauthorizedIdentity, "gate readiness requires worker peer" unless role == :worker
|
|
201
|
+
@lifecycle.gate_ready(request: request, peer: peer, socket: socket, deadline: deadline)
|
|
202
|
+
else
|
|
203
|
+
Molecules::EvidenceJournal.with_event_read_operation do
|
|
204
|
+
binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
|
|
205
|
+
if binding
|
|
206
|
+
raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
|
|
207
|
+
raise ArgumentError, "transfer control header is oversized" if frame.fetch(:bytesize) > 16_384
|
|
208
|
+
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
|
|
209
|
+
admitted_transfer = @mutex.synchronize do
|
|
210
|
+
if @transfers < 2
|
|
211
|
+
@transfers += 1
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
|
|
216
|
+
codec = transfer_codec
|
|
217
|
+
if binding.fetch(:direction) == :upload
|
|
218
|
+
result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
|
|
219
|
+
@lifecycle.dispatch(request: request, peer: peer, role: role, transfer: input)
|
|
220
|
+
end
|
|
221
|
+
else
|
|
222
|
+
result = @lifecycle.dispatch(request: request, peer: peer, role: role)
|
|
223
|
+
parts = result.fetch(:transfer_parts)
|
|
224
|
+
descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
|
|
225
|
+
result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
|
|
226
|
+
end
|
|
227
|
+
else
|
|
228
|
+
raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
|
|
229
|
+
result = @lifecycle.dispatch(request: request, peer: peer, role: role)
|
|
230
|
+
end
|
|
231
|
+
wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
|
|
232
|
+
"transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
|
|
233
|
+
if binding && binding.fetch(:direction) == :download
|
|
234
|
+
codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
|
|
239
|
+
refusal(socket, "invalid_input")
|
|
240
|
+
rescue AttemptErrors::UnauthorizedIdentity, SecurityError
|
|
241
|
+
refusal(socket, "unauthorized")
|
|
242
|
+
rescue AttemptErrors::Conflict
|
|
243
|
+
refusal(socket, "conflict")
|
|
244
|
+
rescue AttemptErrors::NotFound
|
|
245
|
+
refusal(socket, "missing")
|
|
246
|
+
rescue AttemptErrors::BoundedResultUnavailable
|
|
247
|
+
refusal(socket, "bounded_result")
|
|
248
|
+
rescue StandardError
|
|
249
|
+
refusal(socket, "evidence_unavailable")
|
|
250
|
+
ensure
|
|
251
|
+
socket.close unless socket.closed?
|
|
252
|
+
@mutex.synchronize do
|
|
253
|
+
@connections.delete(socket)
|
|
254
|
+
@transfers -= 1 if admitted_transfer
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# These closed read operations have no request body. Half-close proves
|
|
259
|
+
# framing completion before status or artifact data can be exposed.
|
|
260
|
+
def bodyless_read!(socket, deadline)
|
|
261
|
+
loop do
|
|
262
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
263
|
+
raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
264
|
+
bytes = socket.read_nonblock(1, exception: false)
|
|
265
|
+
next if bytes == :wait_readable
|
|
266
|
+
raise ArgumentError, "unexpected read request body" unless bytes.nil?
|
|
267
|
+
return
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def transfer_codec
|
|
272
|
+
@mutex.synchronize do
|
|
273
|
+
@transfer_codec ||= begin
|
|
274
|
+
root = @service.fetch("state_root")
|
|
275
|
+
PrivateDirectory.verify!(root)
|
|
276
|
+
spool = File.join(root, "transfers")
|
|
277
|
+
Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
|
|
278
|
+
TransferCodec.new(root: spool)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def principal?(identity, mapping, uid, gid, groups)
|
|
284
|
+
identity["uid"] == mapping[uid] && identity["gid"] == mapping[gid] && identity["groups"] == mapping[groups]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def refusal(socket, code)
|
|
288
|
+
wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
|
|
289
|
+
deadline: wire.deadline(0.1))
|
|
290
|
+
rescue StandardError
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def wire
|
|
295
|
+
Ace::Runtime::Molecules::ProtectedSocket
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
# Canonical-only original dispatch proof shared by status, root admission
|
|
7
|
+
# and initial/retained inspection imports. Installed peer policy stays in
|
|
8
|
+
# the fixed root transport owner.
|
|
9
|
+
module ServiceCleanupDispatch
|
|
10
|
+
CLEANUP_MUTABLE_SETTLEMENT_FIELDS = %w[state receipt reason claimed_at failed_at no_effect_challenge
|
|
11
|
+
challenge_generation challenge_event_digest completion_digest no_effect_completion_digest].freeze
|
|
12
|
+
|
|
13
|
+
def cleanup_dispatch_context!(record, commit:, selectors: nil, pending: nil)
|
|
14
|
+
unless @journal.evidence_mode == :protected &&
|
|
15
|
+
record.values_at("operation", "dispatch_phase") == ["prune-preserved-workspace", "dispatch_started"]
|
|
16
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch is unavailable"
|
|
17
|
+
end
|
|
18
|
+
pending ||= {commit: commit}
|
|
19
|
+
@journal.verify_canonical_prefix!(commit: commit) unless service_read_view(pending)
|
|
20
|
+
# The existing index authenticates accepted record provenance without
|
|
21
|
+
# recursively asking this terminal collection to validate itself.
|
|
22
|
+
selected = @journal.service_request_records(commit: commit)
|
|
23
|
+
.select { |entry| entry["request_id"] == record.fetch("request_id") }
|
|
24
|
+
# Initial import receives the lower writer's proposed terminal
|
|
25
|
+
# replacement. Its settlement fields are validated by that writer
|
|
26
|
+
# and collection; the original immutable dispatch must still match.
|
|
27
|
+
unless selected.one? && selected.first.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) ==
|
|
28
|
+
record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS)
|
|
29
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup selected record differs"
|
|
30
|
+
end
|
|
31
|
+
context(record, pending: {commit: commit})
|
|
32
|
+
events = service_read_events(record, pending)
|
|
33
|
+
names = %w[request_event_digest dispatch_event_digest]
|
|
34
|
+
operations = %w[request_service begin_dispatch]
|
|
35
|
+
if selectors.nil?
|
|
36
|
+
selectors = names.zip(operations).to_h do |name, operation|
|
|
37
|
+
matches = events.select do |event|
|
|
38
|
+
event["type"] == "authority_mutation" && event.dig("payload", "operation") == operation &&
|
|
39
|
+
event.dig("payload", "data", "request_id") == record.fetch("request_id") &&
|
|
40
|
+
event.dig("payload", "data", "claim_binding") == record.fetch("claim_binding") &&
|
|
41
|
+
event.dig("payload", "data", operation == "request_service" ? "claim" : "invocation") ==
|
|
42
|
+
(operation == "request_service" ? "created" : "permitted")
|
|
43
|
+
end
|
|
44
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance is ambiguous" unless matches.one?
|
|
45
|
+
[name, matches.first.fetch("digest")]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
unless selectors.is_a?(Hash) && selectors.keys.sort == names.sort &&
|
|
49
|
+
selectors.values.all? { |value| value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/) } && selectors.values.uniq.size == 2
|
|
50
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance selectors differ"
|
|
51
|
+
end
|
|
52
|
+
introductions = service_read_introductions(record, selectors.values, pending)
|
|
53
|
+
dispatch_record = nil
|
|
54
|
+
names.zip(operations).each do |name, operation|
|
|
55
|
+
selector = selectors.fetch(name)
|
|
56
|
+
matches = events.select { |event| event.fetch("digest") == selector }
|
|
57
|
+
event = matches.one? && matches.first
|
|
58
|
+
payload = event && event.fetch("payload")
|
|
59
|
+
unless event && event.fetch("type") == "authority_mutation" && payload.fetch("operation") == operation &&
|
|
60
|
+
payload.slice("assignment_id", "attempt_id") == record.slice("assignment_id", "attempt_id") &&
|
|
61
|
+
payload.fetch("data").slice("request_id", "claim_binding") == record.slice("request_id", "claim_binding") &&
|
|
62
|
+
payload.fetch("data").fetch(operation == "request_service" ? "claim" : "invocation") ==
|
|
63
|
+
(operation == "request_service" ? "created" : "permitted")
|
|
64
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance differs"
|
|
65
|
+
end
|
|
66
|
+
prefix = @journal.read_events(record.fetch("assignment_id"), commit: introductions.fetch(selector))
|
|
67
|
+
.select { |entry| entry.fetch("attempt_id") == record.fetch("attempt_id") }
|
|
68
|
+
original = @journal.service_request(record.fetch("request_id"), commit: introductions.fetch(selector))
|
|
69
|
+
expected = operation == "request_service" ? original&.merge("dispatch_phase" => "dispatch_started",
|
|
70
|
+
"operation_owner_binding" => record.fetch("operation_owner_binding"),
|
|
71
|
+
"executor_process_binding" => record.fetch("executor_process_binding")) : original
|
|
72
|
+
unless expected&.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) == record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) &&
|
|
73
|
+
prefix.last == event && prefix[-2] &&
|
|
74
|
+
prefix[-2].fetch("type") == (operation == "request_service" ? "service_claim" : "service_transition") &&
|
|
75
|
+
prefix[-2].dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(original) &&
|
|
76
|
+
event.fetch("previous_digest") == prefix[-2].fetch("digest")
|
|
77
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup acceptance is not its original canonical introduction"
|
|
78
|
+
end
|
|
79
|
+
dispatch_record = original if operation == "begin_dispatch"
|
|
80
|
+
end
|
|
81
|
+
freeze_projection(JSON.parse(JSON.generate(selectors.merge("commit" => commit, "dispatch_record" => dispatch_record))))
|
|
82
|
+
rescue KeyError, TypeError, ArgumentError
|
|
83
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch proof is incomplete"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
4
|
+
require_relative "cleanup_result_protection"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# The existing service evidence owner authenticates this complete pair.
|
|
10
|
+
# Root-private archives remain outside the authority evidence graph.
|
|
11
|
+
module ServiceCleanupEvidence
|
|
12
|
+
ROOT_SELECTION_SCHEMA = "ace.protected-workspace-prune-root-selection/v1"
|
|
13
|
+
ROOT_RECEIPT_SCHEMA = "ace.protected-workspace-prune-receipt/v1"
|
|
14
|
+
ROOT_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest receipt_ref].freeze
|
|
15
|
+
ROOT_INSPECTION_SELECTION_SCHEMA = "ace.protected-workspace-prune-inspection-selection/v1"
|
|
16
|
+
ROOT_INSPECTION_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest inspection_ref].freeze
|
|
17
|
+
ROOT_RECEIPT_FIELDS = %w[schema request_id input_digest maintenance target publication canonical_snapshots preservation removal].freeze
|
|
18
|
+
|
|
19
|
+
# Pre-import decoding reuses the collection validator. Only the lower
|
|
20
|
+
# writer's pending collection can authenticate protected storage and
|
|
21
|
+
# accept a terminal update.
|
|
22
|
+
def cleanup_inspection_inputs!(contents, record, challenge)
|
|
23
|
+
unless record["operation"] == "prune-preserved-workspace" && contents.is_a?(Array) && contents.size == 2
|
|
24
|
+
raise AttemptErrors::ReceiptRejected, "cleanup inspection requires its complete pair"
|
|
25
|
+
end
|
|
26
|
+
cleanup_documents!(contents, record, no_effect: true, challenge: challenge)
|
|
27
|
+
true
|
|
28
|
+
rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError
|
|
29
|
+
raise AttemptErrors::ReceiptRejected, "cleanup inspection pair is unavailable"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def cleanup_collection!(references, record, state, pending)
|
|
35
|
+
unless record["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state) && references.size == 2
|
|
36
|
+
raise AttemptErrors::ReceiptRejected, "cleanup completion requires its complete original evidence pair"
|
|
37
|
+
end
|
|
38
|
+
no_effect = state == "failed-settled"
|
|
39
|
+
challenge = no_effect ? challenge!(record, pending: pending) : nil
|
|
40
|
+
expected = no_effect ? context_for_challenge(record, challenge) : context(record)
|
|
41
|
+
proof = cleanup_dispatch_context!(record, commit: pending && pending[:commit] || @journal.ref_value, pending: pending)
|
|
42
|
+
contents = references.map do |reference|
|
|
43
|
+
if pending && pending[:pending_events]
|
|
44
|
+
@canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
|
|
45
|
+
else
|
|
46
|
+
@canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
reference, operation_bytes = cleanup_documents!(contents, record, no_effect: no_effect,
|
|
50
|
+
challenge: challenge)
|
|
51
|
+
path = reference.fetch("path")
|
|
52
|
+
# The lower writer supplies its pending import transaction. Historical
|
|
53
|
+
# consumers have only a pinned commit and never reopen mutable storage.
|
|
54
|
+
if pending && pending[:pending_events]
|
|
55
|
+
selected_artifacts = @cleanup_artifacts || Ace::Runtime::Molecules::ProtectedArtifactSet.new(
|
|
56
|
+
protection: CleanupResultProtection.new(result_path: path, authority_gid: Process.egid))
|
|
57
|
+
selected_artifacts.with do |artifacts|
|
|
58
|
+
unless artifacts.read!(reference) == operation_bytes
|
|
59
|
+
raise AttemptErrors::ReceiptRejected, "cleanup retained result bytes differ"
|
|
60
|
+
end
|
|
61
|
+
artifacts.verify_unchanged!
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
if (view = service_read_view(pending))
|
|
65
|
+
view.projection = settlement_projection(record, pending, challenge, proof)
|
|
66
|
+
end
|
|
67
|
+
contents.freeze
|
|
68
|
+
rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError, Ace::Runtime::RuntimeUnavailableError
|
|
69
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original evidence is unavailable"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cleanup_documents!(contents, record, no_effect:, challenge:)
|
|
73
|
+
documents = contents.map { |bytes| cleanup_json!(bytes) }
|
|
74
|
+
selection_schema = no_effect ? ROOT_INSPECTION_SELECTION_SCHEMA : ROOT_SELECTION_SCHEMA
|
|
75
|
+
selection_fields = no_effect ? ROOT_INSPECTION_SELECTION_FIELDS : ROOT_SELECTION_FIELDS
|
|
76
|
+
selection_index = documents.each_index.select { |index| documents[index]["schema"] == selection_schema }
|
|
77
|
+
receipt_index = documents.each_index.select do |index|
|
|
78
|
+
no_effect ? documents[index].keys.sort == ServiceEvidence::INSPECTION_FIELDS.sort : documents[index]["schema"] == ROOT_RECEIPT_SCHEMA
|
|
79
|
+
end
|
|
80
|
+
unless selection_index.one? && receipt_index.one?
|
|
81
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original evidence selection is ambiguous"
|
|
82
|
+
end
|
|
83
|
+
selection = documents.fetch(selection_index.first)
|
|
84
|
+
receipt = documents.fetch(receipt_index.first)
|
|
85
|
+
closed_cleanup!(selection, selection_fields)
|
|
86
|
+
closed_cleanup!(receipt, no_effect ? ServiceEvidence::INSPECTION_FIELDS : ROOT_RECEIPT_FIELDS)
|
|
87
|
+
unless [selection, receipt].all? { |value| value["request_id"] == record.fetch("request_id") && value["input_digest"] == record.fetch("input_digest") } &&
|
|
88
|
+
selection["operation_owner_binding_digest"] == Atoms::EvidenceDigest.digest(record.fetch("operation_owner_binding"))
|
|
89
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original owner or request differs"
|
|
90
|
+
end
|
|
91
|
+
reference = selection.fetch(no_effect ? "inspection_ref" : "receipt_ref")
|
|
92
|
+
closed_cleanup!(reference, %w[path bytes sha256])
|
|
93
|
+
path = reference.fetch("path")
|
|
94
|
+
operation_bytes = contents.fetch(receipt_index.first)
|
|
95
|
+
unless path.is_a?(String) && path.bytesize <= 4096 && path.valid_encoding? && !path.include?("\0") &&
|
|
96
|
+
path.start_with?("/") && File.expand_path(path) == path &&
|
|
97
|
+
reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536) &&
|
|
98
|
+
reference["bytes"] == operation_bytes.bytesize && reference["sha256"] == Digest::SHA256.hexdigest(operation_bytes)
|
|
99
|
+
raise AttemptErrors::ReceiptRejected, "cleanup retained result reference differs"
|
|
100
|
+
end
|
|
101
|
+
if no_effect
|
|
102
|
+
inspection_projection!(receipt, record, challenge)
|
|
103
|
+
else
|
|
104
|
+
cleanup_receipt_projection!(receipt, record)
|
|
105
|
+
end
|
|
106
|
+
[reference, operation_bytes]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cleanup_json!(bytes)
|
|
110
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536)
|
|
111
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence exceeds bounds"
|
|
112
|
+
end
|
|
113
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
114
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence is invalid UTF8" unless text.valid_encoding? && !text.include?("\0")
|
|
115
|
+
value = JSON.parse(text, create_additions: false, max_nesting: 16, allow_duplicate_key: false, allow_comments: false)
|
|
116
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence is not an object" unless value.is_a?(Hash)
|
|
117
|
+
value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def closed_cleanup!(value, fields)
|
|
121
|
+
unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
122
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence fields differ"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cleanup_receipt_projection!(receipt, record)
|
|
127
|
+
preservation = receipt.fetch("preservation")
|
|
128
|
+
closed_cleanup!(preservation, %w[head branch destinations manifest_sha256 inventory_sha256 file_count total_bytes private_manifest_sha256 archives_sha256])
|
|
129
|
+
unless preservation["file_count"].is_a?(Integer) && preservation["file_count"].between?(0, 4096) &&
|
|
130
|
+
preservation["total_bytes"].is_a?(Integer) && preservation["total_bytes"].between?(0, 256 * 1024 * 1024) &&
|
|
131
|
+
%w[manifest_sha256 inventory_sha256 private_manifest_sha256 archives_sha256].all? { |key| cleanup_sha?(preservation[key]) } &&
|
|
132
|
+
preservation.values_at("manifest_sha256", "inventory_sha256", "private_manifest_sha256").uniq.one?
|
|
133
|
+
raise AttemptErrors::ReceiptRejected, "cleanup preservation observation differs"
|
|
134
|
+
end
|
|
135
|
+
input = receipt.slice("maintenance", "target", "publication").merge("schema" => "ace.protected-workspace-prune/v1",
|
|
136
|
+
"preservation" => preservation.slice("head", "branch", "destinations", "manifest_sha256"))
|
|
137
|
+
unless Atoms::EvidenceDigest.digest(input) == record.fetch("input_digest") &&
|
|
138
|
+
receipt.fetch("maintenance") == record.slice("project_id", "mapping_id", "assignment_id", "attempt_id")
|
|
139
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original input differs"
|
|
140
|
+
end
|
|
141
|
+
snapshots = receipt.fetch("canonical_snapshots")
|
|
142
|
+
unless snapshots.is_a?(Array) && snapshots.size.between?(1, 256)
|
|
143
|
+
raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots exceed bounds"
|
|
144
|
+
end
|
|
145
|
+
snapshots.each do |snapshot|
|
|
146
|
+
closed_cleanup!(snapshot, %w[mapping_id journal_commit binding_event_digest release_event_digest proof_event_digest])
|
|
147
|
+
unless snapshot["mapping_id"].is_a?(String) && snapshot["mapping_id"].match?(Molecules::JournalMutation::ID) &&
|
|
148
|
+
snapshot["journal_commit"].is_a?(String) && snapshot["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
149
|
+
%w[binding_event_digest release_event_digest proof_event_digest].all? { |key| cleanup_sha?(snapshot[key]) }
|
|
150
|
+
raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshot differs"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
ids = snapshots.map { |snapshot| snapshot.fetch("mapping_id") }
|
|
154
|
+
raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots are not uniquely ordered" unless ids == ids.uniq.sort
|
|
155
|
+
removal = receipt.fetch("removal")
|
|
156
|
+
closed_cleanup!(removal, %w[original captured outcome fence_digest])
|
|
157
|
+
unless removal["outcome"] == "removed" && cleanup_sha?(removal["fence_digest"])
|
|
158
|
+
raise AttemptErrors::ReceiptRejected, "cleanup has no positive removal observation"
|
|
159
|
+
end
|
|
160
|
+
%w[original captured].each do |key|
|
|
161
|
+
object = removal.fetch(key)
|
|
162
|
+
closed_cleanup!(object, %w[device inode worktree_admin_id head branch])
|
|
163
|
+
unless %w[device inode].all? { |field| object[field].is_a?(Integer) && object[field].positive? } &&
|
|
164
|
+
object["worktree_admin_id"].is_a?(String) && object["worktree_admin_id"].bytesize.between?(1, 200) &&
|
|
165
|
+
object["worktree_admin_id"].match?(/\A[a-zA-Z0-9_.-]+\z/) &&
|
|
166
|
+
object["head"] == preservation["head"] && object["branch"] == preservation["branch"]
|
|
167
|
+
raise AttemptErrors::ReceiptRejected, "cleanup captured object differs"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
unless removal.fetch("original") == removal.fetch("captured")
|
|
171
|
+
raise AttemptErrors::ReceiptRejected, "cleanup replaced object cannot attest removal"
|
|
172
|
+
end
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def cleanup_sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|