ace-assign 0.64.0 → 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/CHANGELOG.md +19 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- 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 +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- 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 +3 -280
- 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 +36 -18
- 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 +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- 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 +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- 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 +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- 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 +5 -2
- 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/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- 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 +243 -12
- 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 +44 -12
- 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 +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- 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/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 +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- 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 +12 -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 +36 -5
- 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 +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- 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 +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -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 +21 -15
- 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 +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- 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 +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +88 -9
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class LaunchLifecycle
|
|
7
|
+
# Fixed source readers reuse the complete canonical inventory and its
|
|
8
|
+
# original registration/definition proof; this is never a live grant.
|
|
9
|
+
def preview_attempt_events!(journal:, commit:, params:, map:)
|
|
10
|
+
preview_attempt_entry!(journal: journal, commit: commit, params: params, map: map).fetch(:events)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def preview_attempt_definition!(journal:, commit:, params:, map:)
|
|
14
|
+
entry = preview_attempt_entry!(journal: journal, commit: commit, params: params, map: map)
|
|
15
|
+
inventory_definition!(journal, commit, entry)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def preview_attempt_entry!(journal:, commit:, params:, map:)
|
|
21
|
+
entries = inventory_index!(journal, commit, params.fetch("mapping_id"), map).select do |entry|
|
|
22
|
+
entry.fetch(:selector) == params.slice("assignment_id", "attempt_id")
|
|
23
|
+
end
|
|
24
|
+
raise AttemptErrors::EvidenceUnavailable, "preview original registration is unavailable or ambiguous" unless entries.one?
|
|
25
|
+
entry = entries.first
|
|
26
|
+
inventory_definition!(journal, commit, entry)
|
|
27
|
+
entry
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Discovery authenticates the current mapped principal, then reads one
|
|
31
|
+
# immutable canonical prefix. It neither pins the original launcher nor
|
|
32
|
+
# grants permission to mutate that launch.
|
|
33
|
+
def assignment_inventory!(request:, peer:, role:)
|
|
34
|
+
params = request.fetch("params")
|
|
35
|
+
strict!(params, %w[mapping_id journal_commit after limit])
|
|
36
|
+
raise ArgumentError, "Inventory mutation ID must be null" unless request.fetch("mutation_id").nil?
|
|
37
|
+
unless %i[launcher supervisor].include?(role)
|
|
38
|
+
raise AttemptErrors::UnauthorizedIdentity, "Inventory requires mapped launcher or supervisor"
|
|
39
|
+
end
|
|
40
|
+
map = steering_principal!(params, peer, role)
|
|
41
|
+
limit = params.fetch("limit")
|
|
42
|
+
raise ArgumentError, "Inventory limit is invalid" unless limit.is_a?(Integer) && limit.between?(1, 50)
|
|
43
|
+
after = params.fetch("after")
|
|
44
|
+
unless after.nil?
|
|
45
|
+
strict!(after, %w[assignment_id attempt_id])
|
|
46
|
+
token!(after.fetch("assignment_id"))
|
|
47
|
+
token!(after.fetch("attempt_id")) unless after.fetch("attempt_id").nil?
|
|
48
|
+
raise ArgumentError, "Inventory continuation requires selected commit" if params.fetch("journal_commit").nil?
|
|
49
|
+
end
|
|
50
|
+
journal = journal_for(map)
|
|
51
|
+
canonical_commit = journal.ref_value
|
|
52
|
+
commit = params.fetch("journal_commit") || canonical_commit
|
|
53
|
+
journal.verify_canonical_prefix!(commit: commit, canonical_commit: canonical_commit)
|
|
54
|
+
indexed = inventory_index!(journal, commit, params.fetch("mapping_id"), map)
|
|
55
|
+
offset = 0
|
|
56
|
+
if after
|
|
57
|
+
position = indexed.index { |entry| entry.fetch(:selector) == after }
|
|
58
|
+
raise AttemptErrors::NotFound, "Inventory cursor does not select a visible row" unless position
|
|
59
|
+
offset = position + 1
|
|
60
|
+
end
|
|
61
|
+
data = {"project_id" => map.fetch("project_id"), "mapping_id" => params.fetch("mapping_id"),
|
|
62
|
+
"journal_commit" => commit, "items" => [], "next_after" => nil}
|
|
63
|
+
indexed.drop(offset).first(limit).each do |entry|
|
|
64
|
+
row = inventory_row!(journal, commit, entry)
|
|
65
|
+
proposed = data.merge("items" => data.fetch("items") + [row],
|
|
66
|
+
"next_after" => offset + data.fetch("items").size + 1 < indexed.size ? entry.fetch(:selector) : nil)
|
|
67
|
+
# Size the actual fixed public envelope, retaining whole rows and
|
|
68
|
+
# an honest cursor without truncating or omitting canonical facts.
|
|
69
|
+
frame = {"status" => "ok", "data" => proposed, "transport" => {"replayed" => false}}
|
|
70
|
+
if JSON.generate(frame).bytesize + 1 > 16_384
|
|
71
|
+
raise AttemptErrors::BoundedResultUnavailable, "Inventory row exceeds bounded response" if data.fetch("items").empty?
|
|
72
|
+
break
|
|
73
|
+
end
|
|
74
|
+
data = proposed
|
|
75
|
+
end
|
|
76
|
+
{data: data, replayed: false}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inventory_index!(journal, commit, mapping_id, map)
|
|
80
|
+
authenticated = journal.canonical_event_inventory!(commit: commit)
|
|
81
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory authenticated snapshot differs" unless authenticated.fetch("commit") == commit
|
|
82
|
+
snapshots = authenticated.fetch("events")
|
|
83
|
+
introductions = authenticated.fetch("introductions")
|
|
84
|
+
entries = snapshots.flat_map do |assignment_id, events|
|
|
85
|
+
registrations = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "register_assignment" }
|
|
86
|
+
next [] if registrations.empty?
|
|
87
|
+
attempts = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
|
|
88
|
+
if attempts.empty?
|
|
89
|
+
registration = registrations.last.fetch("payload").fetch("data")
|
|
90
|
+
next [] unless registration["mapping_id"] == mapping_id
|
|
91
|
+
authenticate_inventory_registration!(journal, assignment_id, events, registration, mapping_id, map, commit, introductions.fetch(assignment_id))
|
|
92
|
+
next [{selector: {"assignment_id" => assignment_id, "attempt_id" => nil}, registration: registration, map: map}]
|
|
93
|
+
end
|
|
94
|
+
selected = attempts.select { |event| event.dig("payload", "data", "mapping_id") == mapping_id }
|
|
95
|
+
next [] if selected.empty?
|
|
96
|
+
prefixes = introductions.fetch(assignment_id)
|
|
97
|
+
selected.map do |reserve|
|
|
98
|
+
attempt_id = reserve.fetch("attempt_id")
|
|
99
|
+
chain = events.select { |event| event["attempt_id"] == attempt_id }
|
|
100
|
+
registration = definition(journal, assignment_id, commit: prefixes.fetch(reserve.fetch("digest")))
|
|
101
|
+
unless registration && registration.values_at("assignment_id", "project_id", "mapping_id") == [assignment_id, map.fetch("project_id"), mapping_id]
|
|
102
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory reservation registration differs"
|
|
103
|
+
end
|
|
104
|
+
registration_events = journal.read_events(assignment_id, commit: prefixes.fetch(reserve.fetch("digest")))
|
|
105
|
+
authenticate_inventory_registration!(journal, assignment_id, registration_events, registration, mapping_id, map, prefixes.fetch(reserve.fetch("digest")), prefixes)
|
|
106
|
+
{selector: {"assignment_id" => assignment_id, "attempt_id" => attempt_id},
|
|
107
|
+
registration: registration, events: chain, map: map, introductions: prefixes}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
selectors = entries.map { |entry| entry.fetch(:selector).values_at("assignment_id", "attempt_id") }
|
|
111
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory row identity is ambiguous" unless selectors.uniq.size == selectors.size
|
|
112
|
+
entries.sort_by { |entry| [entry.fetch(:selector).fetch("assignment_id").b, entry.fetch(:selector).fetch("attempt_id").to_s.b] }
|
|
113
|
+
rescue KeyError, TypeError, NoMethodError
|
|
114
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory canonical index is malformed"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def authenticate_inventory_registration!(journal, assignment_id, events, registration, mapping_id, map, commit, introductions)
|
|
118
|
+
unless registration.is_a?(Hash) && registration.values_at("assignment_id", "project_id", "mapping_id", "phase") ==
|
|
119
|
+
[assignment_id, map.fetch("project_id"), mapping_id, "registered"]
|
|
120
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory accepted registration association differs"
|
|
121
|
+
end
|
|
122
|
+
chain = events.select { |event| event["attempt_id"] == "definition-#{assignment_id}" }
|
|
123
|
+
journal.authority_generation(chain)
|
|
124
|
+
accepted = chain.select { |event| event["type"] == "authority_mutation" &&
|
|
125
|
+
event.dig("payload", "operation") == "register_assignment" && event.dig("payload", "data") == registration }
|
|
126
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory registration provenance is ambiguous" unless accepted.one?
|
|
127
|
+
event = accepted.first
|
|
128
|
+
prefix = introductions.fetch(event.fetch("digest"))
|
|
129
|
+
original_chain = journal.read_events(assignment_id, commit: prefix).select { |item| item["attempt_id"] == "definition-#{assignment_id}" }
|
|
130
|
+
unless original_chain.last == event && registration["generation"].is_a?(Integer) &&
|
|
131
|
+
registration["generation"] == journal.authority_generation(original_chain)
|
|
132
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory registration generation/introduction differs"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def inventory_definition!(journal, commit, entry)
|
|
137
|
+
selector, registration = entry.values_at(:selector, :registration)
|
|
138
|
+
bytes = journal.blob(registration.fetch("definition_ref"), commit: commit)
|
|
139
|
+
unless bytes.is_a?(String) && bytes.bytesize <= 32_768 &&
|
|
140
|
+
Digest::SHA256.hexdigest(bytes) == registration.fetch("definition_digest")
|
|
141
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory definition content differs"
|
|
142
|
+
end
|
|
143
|
+
value = JSON.parse(bytes)
|
|
144
|
+
prepared = value.fetch("prepared_work")
|
|
145
|
+
unless registration.fetch("prepared_work") == prepared && prepared.fetch("task_id") == registration.fetch("task_id") &&
|
|
146
|
+
registration.fetch("selection_sha256") == prepared.fetch("selection_sha256") &&
|
|
147
|
+
registration.fetch("prepared_bundle_sha256").is_a?(String) && registration.fetch("prepared_bundle_sha256").match?(PreparedWork::SHA) &&
|
|
148
|
+
registration.fetch("prepared_bundle_bytes").is_a?(Integer) && registration.fetch("prepared_bundle_bytes").between?(1, PreparedWork::MAX_TOTAL) &&
|
|
149
|
+
registration.fetch("prepared_bundle_ref") == "execution/prepared/#{selector.fetch('assignment_id')}-#{registration.fetch('prepared_bundle_sha256')}.bundle"
|
|
150
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory prepared input identity differs"
|
|
151
|
+
end
|
|
152
|
+
assignment = Models::Assignment.from_h(value)
|
|
153
|
+
unless assignment.managed? && assignment.id == selector.fetch("assignment_id") &&
|
|
154
|
+
assignment.project_id == registration.fetch("project_id") && assignment.task_id == registration.fetch("task_id") &&
|
|
155
|
+
registration.fetch("definition_generation").is_a?(Integer) && registration.fetch("definition_generation").positive?
|
|
156
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory definition provenance differs"
|
|
157
|
+
end
|
|
158
|
+
assignment
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def inventory_row!(journal, commit, entry)
|
|
162
|
+
inventory_definition!(journal, commit, entry)
|
|
163
|
+
selector, registration = entry.values_at(:selector, :registration)
|
|
164
|
+
row = selector.merge(registration.slice("task_id", "definition_digest", "definition_generation", "prepared_bundle_ref", "prepared_bundle_bytes", "prepared_bundle_sha256", "selection_sha256"),
|
|
165
|
+
"scope" => nil, "reservation_mutation_id" => nil, "base_head" => nil, "reservation_generation" => nil, "generation" => nil, "canonical_state" => nil,
|
|
166
|
+
"original_binding_digest" => nil, "terminal_event_id" => nil, "reservation_release_event_id" => nil)
|
|
167
|
+
return row unless selector.fetch("attempt_id")
|
|
168
|
+
events = entry.fetch(:events)
|
|
169
|
+
map = entry.fetch(:map)
|
|
170
|
+
state = origin(events, **selector.merge("mapping_id" => registration.fetch("mapping_id")).transform_keys(&:to_sym))
|
|
171
|
+
generation = journal.authority_generation(events)
|
|
172
|
+
unless generation.is_a?(Integer) && generation.positive? && state.fetch("reservation_generation").is_a?(Integer) && state.fetch("reservation_generation").positive?
|
|
173
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory attempt generation is invalid"
|
|
174
|
+
end
|
|
175
|
+
reservations = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
|
|
176
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory original reservation is ambiguous" unless reservations.one?
|
|
177
|
+
reservation = reservations.first.fetch("payload")
|
|
178
|
+
mutation_id = reservation.fetch("mutation_id")
|
|
179
|
+
token!(mutation_id)
|
|
180
|
+
base_head = reservation.fetch("data").fetch("base_head")
|
|
181
|
+
unless base_head.is_a?(String) && base_head.match?(/\A[0-9a-f]{40}\z/) && state.fetch("base_head") == base_head
|
|
182
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory original reservation base differs"
|
|
183
|
+
end
|
|
184
|
+
row.merge!("scope" => state.fetch("scope"), "reservation_mutation_id" => mutation_id, "base_head" => base_head, "reservation_generation" => state.fetch("reservation_generation"),
|
|
185
|
+
"generation" => generation, "canonical_state" => journal.canonical_attempt_state(events))
|
|
186
|
+
if events.any? { |event| event.dig("payload", "operation") == "record_launch" }
|
|
187
|
+
row["original_binding_digest"] = original_prompt_record!(events, state,
|
|
188
|
+
params: selector.merge("mapping_id" => registration.fetch("mapping_id"))).fetch("binding_digest")
|
|
189
|
+
end
|
|
190
|
+
terminal_events = events.select { |event| %w[receipt_accepted attempt_stopped].include?(event["type"]) }
|
|
191
|
+
releases = events.select { |event| event.dig("payload", "operation") == "scope_reservation_release" }
|
|
192
|
+
guarded_abort = events.any? { |event| event.dig("payload", "operation") == "abort_launch" }
|
|
193
|
+
return row if terminal_events.empty? && releases.empty? && !guarded_abort
|
|
194
|
+
original, original_map = inventory_original_deployment!(journal, events, registration.fetch("mapping_id"), map)
|
|
195
|
+
lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: original_map.fetch("project_id"),
|
|
196
|
+
assignment_id: selector.fetch("assignment_id"), attempt_id: selector.fetch("attempt_id"), mapping_id: registration.fetch("mapping_id"))
|
|
197
|
+
introductions = entry.fetch(:introductions)
|
|
198
|
+
terminal_introduction = nil
|
|
199
|
+
terminal = if events.any? { |event| event["type"] == "attempt_stopped" }
|
|
200
|
+
stopped = events.find { |event| event["type"] == "attempt_stopped" }
|
|
201
|
+
accepted = events.select { |event| event["type"] == "authority_mutation" && event["previous_digest"] == stopped.fetch("digest") }
|
|
202
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory stopped acceptance is ambiguous" unless accepted.one?
|
|
203
|
+
terminal_introduction = introductions.fetch(accepted.first.fetch("digest"))
|
|
204
|
+
verify_stopped_terminal_at_prefix!(events, journal, terminal_introduction, deployment: original)
|
|
205
|
+
else
|
|
206
|
+
inventory_terminal_at_introduction!(journal, selector, events, original, original_map, registration.fetch("mapping_id"), introductions)
|
|
207
|
+
end
|
|
208
|
+
row["terminal_event_id"] = terminal.fetch("digest")
|
|
209
|
+
unless releases.empty?
|
|
210
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory release is ambiguous" unless releases.one?
|
|
211
|
+
release = releases.first
|
|
212
|
+
prefix = introductions.fetch(release.fetch("digest"))
|
|
213
|
+
verify_released_lineage_at_prefix!(journal, prefix, selector.fetch("assignment_id"), selector.fetch("attempt_id"), events, original, original_map, terminal_introduction: terminal_introduction)
|
|
214
|
+
row["reservation_release_event_id"] = release.fetch("digest")
|
|
215
|
+
end
|
|
216
|
+
row
|
|
217
|
+
rescue KeyError, TypeError, JSON::ParserError, NoMethodError
|
|
218
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory canonical row is malformed"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def inventory_terminal_at_introduction!(journal, selector, events, original, map, mapping_id, introductions)
|
|
222
|
+
terminals = events.select { |event| event["type"] == "receipt_accepted" ||
|
|
223
|
+
(event["type"] == "authority_mutation" && event.dig("payload", "operation") == "abort_launch") }
|
|
224
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory terminal provenance is ambiguous" unless terminals.one?
|
|
225
|
+
event = terminals.first
|
|
226
|
+
prefix_commit = introductions.fetch(event.fetch("digest"))
|
|
227
|
+
prefix = journal.read_events(selector.fetch("assignment_id"), commit: prefix_commit).select { |entry| entry["attempt_id"] == selector.fetch("attempt_id") }
|
|
228
|
+
terminal_length = events.index(event) + 1
|
|
229
|
+
if event["type"] == "receipt_accepted"
|
|
230
|
+
transition, acceptance = events.drop(terminal_length).take(2)
|
|
231
|
+
if transition && acceptance && transition["type"] == "transition" &&
|
|
232
|
+
acceptance["type"] == "authority_mutation" && acceptance.dig("payload", "operation") == "finish"
|
|
233
|
+
unless introductions.fetch(transition.fetch("digest")) == prefix_commit &&
|
|
234
|
+
introductions.fetch(acceptance.fetch("digest")) == prefix_commit
|
|
235
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory finish acceptance introduction differs"
|
|
236
|
+
end
|
|
237
|
+
terminal_length += 2
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
unless prefix == events.take(terminal_length)
|
|
241
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory terminal introduction differs"
|
|
242
|
+
end
|
|
243
|
+
lineage = Molecules::ExecutionScopeLineage.new(events: prefix, project_id: map.fetch("project_id"),
|
|
244
|
+
assignment_id: selector.fetch("assignment_id"), attempt_id: selector.fetch("attempt_id"), mapping_id: mapping_id)
|
|
245
|
+
terminal = terminal_scope_receipt!(prefix, lineage, journal, prefix_commit, deployment: original)
|
|
246
|
+
if prefix.any? { |entry| entry.dig("payload", "operation") == "finish" }
|
|
247
|
+
unless lineage.proof_event && !pending_prompt_issuers?(prefix, journal, prefix_commit) && @result_owner
|
|
248
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory finish closure owner is unavailable"
|
|
249
|
+
end
|
|
250
|
+
lineage.require_positive!(scope_generation: lineage.binding.fetch("scope_generation"),
|
|
251
|
+
scope_binding_event_id: lineage.binding_event.fetch("digest"), seal_event_id: lineage.seal_event.fetch("digest"), proof_id: lineage.proof_id)
|
|
252
|
+
params = selector.merge("mapping_id" => mapping_id)
|
|
253
|
+
if terminal.dig("payload", "receipt", "verdict") == "succeeded"
|
|
254
|
+
finish = prefix.find { |entry| entry.dig("payload", "operation") == "finish" }
|
|
255
|
+
review_params = params.merge(finish.fetch("payload").fetch("data").slice("head", "candidate_generation"))
|
|
256
|
+
@result_owner.finished_review_evidence!(journal: journal, events: prefix, params: review_params,
|
|
257
|
+
map: map, commit: prefix_commit)
|
|
258
|
+
end
|
|
259
|
+
@result_owner.service_settlement_complete!(journal: journal, events: prefix, params: params, map: map, commit: prefix_commit)
|
|
260
|
+
end
|
|
261
|
+
terminal
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def inventory_original_deployment!(journal, events, mapping_id, current_map)
|
|
265
|
+
provisioning = events.select { |event| event["type"] == "scope_provisioning" }
|
|
266
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory original provisioning is ambiguous" unless provisioning.one?
|
|
267
|
+
identity = provisioning.first.fetch("payload")
|
|
268
|
+
unless identity.is_a?(Hash) && identity.keys.sort == %w[deployment_digest descriptor_sha256 reservation_generation slot_id]
|
|
269
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory original provisioning is malformed"
|
|
270
|
+
end
|
|
271
|
+
original = if identity.fetch("descriptor_sha256") == protected_descriptor_sha256!
|
|
272
|
+
@deployment
|
|
273
|
+
else
|
|
274
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory original descriptor owner unavailable" unless @deployment_history
|
|
275
|
+
@deployment_history.descriptor!(sha256: identity.fetch("descriptor_sha256"))
|
|
276
|
+
end
|
|
277
|
+
map = original.mapping(mapping_id)
|
|
278
|
+
project = original.project(map.fetch("project_id"))
|
|
279
|
+
unless map.fetch("project_id") == current_map.fetch("project_id") &&
|
|
280
|
+
map.fetch("execution_scope").fetch("slot_id") == identity.fetch("slot_id") &&
|
|
281
|
+
original.mapping_digest(mapping_id) == identity.fetch("deployment_digest") &&
|
|
282
|
+
[project.fetch("journal_repository"), project.fetch("evidence_git_ref"), project.fetch("evidence_checkout_root")] == [journal.repo_root, journal.ref, journal.checkout_root]
|
|
283
|
+
raise AttemptErrors::EvidenceUnavailable, "Inventory original descriptor association differs"
|
|
284
|
+
end
|
|
285
|
+
[original, map]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
4
|
+
require_relative "campaign_execution"
|
|
5
|
+
require_relative "posix_acl"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# One accepted descriptor reference, held for the entire store/CAS guard.
|
|
11
|
+
# There is deliberately no ordinary review configuration cascade here.
|
|
12
|
+
class CampaignConsumerPolicy
|
|
13
|
+
class Protection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
|
|
14
|
+
def initialize(acl: PosixAcl.new, **options)
|
|
15
|
+
super(**options)
|
|
16
|
+
@acl = acl
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def verify!(path, handle, directory:)
|
|
20
|
+
super
|
|
21
|
+
unless @acl.entries(path).nil? && (!directory || @acl.entries(path, attribute: "system.posix_acl_default").nil?)
|
|
22
|
+
raise Ace::Runtime::RuntimeUnavailableError, "campaign policy ACL differs"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
def self.reference!(reference)
|
|
27
|
+
unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
|
|
28
|
+
reference["path"].is_a?(String) && reference["path"].encoding == Encoding::UTF_8 &&
|
|
29
|
+
reference["path"].valid_encoding? && reference["path"].bytesize.between?(1, 4096) &&
|
|
30
|
+
!reference["path"].include?("\0") && reference["path"].start_with?("/") &&
|
|
31
|
+
File.expand_path(reference["path"]) == reference["path"] &&
|
|
32
|
+
reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
33
|
+
reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536)
|
|
34
|
+
raise ArgumentError, "invalid campaign consumer policy reference"
|
|
35
|
+
end
|
|
36
|
+
reference
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(artifacts: Ace::Runtime::Molecules::ProtectedArtifactSet.new(protection: Protection.new))
|
|
40
|
+
@artifacts = artifacts
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with(reference)
|
|
44
|
+
begin
|
|
45
|
+
self.class.reference!(reference)
|
|
46
|
+
rescue ArgumentError
|
|
47
|
+
unavailable!
|
|
48
|
+
end
|
|
49
|
+
@artifacts.with do |artifacts|
|
|
50
|
+
profiles = read_profiles!(artifacts, reference)
|
|
51
|
+
verify_artifacts!(artifacts)
|
|
52
|
+
result = yield profiles, artifacts
|
|
53
|
+
verify_artifacts!(artifacts)
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def read_profiles!(artifacts, reference)
|
|
61
|
+
bytes = artifacts.read!(reference).dup.force_encoding(Encoding::UTF_8)
|
|
62
|
+
raise ArgumentError, "campaign consumer policy is not UTF-8" unless bytes.valid_encoding?
|
|
63
|
+
value = JSON.parse(bytes, create_additions: false, max_nesting: 16,
|
|
64
|
+
allow_duplicate_key: false, allow_comments: false)
|
|
65
|
+
unless value.is_a?(Hash) && value.keys.sort == %w[profiles schema] &&
|
|
66
|
+
value["schema"] == "ace.review.consumer-policy/v1" && value["profiles"].is_a?(Hash) &&
|
|
67
|
+
value["profiles"].size <= 32
|
|
68
|
+
raise ArgumentError, "campaign consumer policy fields differ"
|
|
69
|
+
end
|
|
70
|
+
profiles = value.fetch("profiles").to_h do |id, policy|
|
|
71
|
+
CampaignExecution::Contract.id!(id, "consumer profile")
|
|
72
|
+
[id, policy.nil? ? nil : CampaignExecution::Contract.policy!(policy)]
|
|
73
|
+
end
|
|
74
|
+
freeze_value!(profiles)
|
|
75
|
+
profiles
|
|
76
|
+
rescue ArgumentError, KeyError, TypeError, JSON::ParserError, Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
77
|
+
unavailable!
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def verify_artifacts!(artifacts)
|
|
81
|
+
artifacts.verify_unchanged!
|
|
82
|
+
rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
83
|
+
unavailable!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def unavailable!
|
|
87
|
+
raise AttemptErrors::EvidenceUnavailable, "current campaign consumer policy is unavailable"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def freeze_value!(value)
|
|
91
|
+
case value
|
|
92
|
+
when Hash then value.each { |key, child| key.freeze; freeze_value!(child) }
|
|
93
|
+
when Array then value.each { |child| freeze_value!(child) }
|
|
94
|
+
end
|
|
95
|
+
value.freeze
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git/atoms/pr_identifier"
|
|
4
|
+
require "ace/review/atoms/campaign_contract"
|
|
5
|
+
require_relative "../molecules/receipt_verifier"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Closed child linkage. Validation is structural; the registration owner
|
|
11
|
+
# must independently compare it with canonical parent/campaign state.
|
|
12
|
+
module CampaignExecution
|
|
13
|
+
Contract = Ace::Review::Atoms::CampaignContract
|
|
14
|
+
FIELDS = %w[version parent_assignment_id parent_attempt_id parent_scope parent_candidate_generation
|
|
15
|
+
subject campaign_id contract_identity policy_digest round_id phase review_scope scope_identity_digest
|
|
16
|
+
head base operation check_name].sort.freeze
|
|
17
|
+
|
|
18
|
+
def self.validate_parent!(value)
|
|
19
|
+
unless value.is_a?(Hash) && value.keys.sort == %w[campaign_id contract_identity policy subject version] &&
|
|
20
|
+
value["version"].is_a?(Integer) && value["version"] == 1
|
|
21
|
+
raise ArgumentError, "parent campaign fields differ"
|
|
22
|
+
end
|
|
23
|
+
Contract.id!(value["campaign_id"], "campaign ID")
|
|
24
|
+
unless value["contract_identity"].is_a?(String) && /\A[0-9a-f]{64}\z/.match?(value["contract_identity"]) &&
|
|
25
|
+
Contract.subject!(value["subject"]) == value["subject"]
|
|
26
|
+
raise ArgumentError, "parent campaign identity differs"
|
|
27
|
+
end
|
|
28
|
+
Contract.policy!(value["policy"])
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.validate_round!(value, parent:, round:)
|
|
33
|
+
validate!(value, parent: parent, policy: round.fetch("policy"))
|
|
34
|
+
binding = round.fetch("binding")
|
|
35
|
+
expected = round.slice("campaign_id", "subject", "contract_identity", "round_id").merge(
|
|
36
|
+
"head" => binding.fetch("head"), "base" => binding.fetch("base"),
|
|
37
|
+
"scope_identity_digest" => Contract.digest(binding.fetch("scope_identity").fetch(value.fetch("review_scope"))))
|
|
38
|
+
unless expected.all? { |key, item| value.fetch(key) == item }
|
|
39
|
+
raise ArgumentError, "campaign execution differs from pinned round"
|
|
40
|
+
end
|
|
41
|
+
value
|
|
42
|
+
rescue KeyError, TypeError
|
|
43
|
+
raise ArgumentError, "campaign execution round is incomplete"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# This does not accept a receipt; the canonical result owner still
|
|
47
|
+
# validates its producer, artifacts and introduction before use.
|
|
48
|
+
def self.validate_result!(value, receipt:, base:)
|
|
49
|
+
unless receipt.is_a?(Hash) && receipt["campaign"].nil? &&
|
|
50
|
+
receipt["operation"] == value.fetch("operation") &&
|
|
51
|
+
receipt["head"] == value.fetch("head") && base == value.fetch("base")
|
|
52
|
+
raise ArgumentError, "campaign child result differs from registered phase or candidate"
|
|
53
|
+
end
|
|
54
|
+
if receipt["verdict"] == "succeeded" && !Array(receipt["checks"]).any? { |check|
|
|
55
|
+
check.is_a?(Hash) && check["name"] == value.fetch("check_name") && check["verdict"] == "passed" }
|
|
56
|
+
raise ArgumentError, "campaign child result lacks its executed phase check"
|
|
57
|
+
end
|
|
58
|
+
value
|
|
59
|
+
rescue KeyError, TypeError
|
|
60
|
+
raise ArgumentError, "campaign child result binding is incomplete"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.validate_finished_review!(value, receipt:, accepted_review:)
|
|
64
|
+
executed = Array(receipt.fetch("artifacts")).map { |ref| ref.fetch("sha256") }
|
|
65
|
+
reviewed = Array(accepted_review.fetch("artifacts")).map { |ref| ref.fetch("sha256") }
|
|
66
|
+
unless (executed - reviewed).empty?
|
|
67
|
+
raise ArgumentError, "independent campaign review does not cover executed artifacts"
|
|
68
|
+
end
|
|
69
|
+
if value.fetch("phase") == "approval" && receipt["review"] != accepted_review.fetch("review")
|
|
70
|
+
raise ArgumentError, "campaign approval differs from canonical independent review"
|
|
71
|
+
end
|
|
72
|
+
true
|
|
73
|
+
rescue KeyError, TypeError
|
|
74
|
+
raise ArgumentError, "campaign independent review binding is incomplete"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.validate!(value, parent:, policy:)
|
|
78
|
+
unless value.is_a?(Hash) && value.keys.sort == FIELDS && value["version"].is_a?(Integer) && value["version"] == 1
|
|
79
|
+
raise ArgumentError, "campaign execution fields differ"
|
|
80
|
+
end
|
|
81
|
+
%w[parent_assignment_id parent_attempt_id parent_scope campaign_id round_id review_scope].each do |key|
|
|
82
|
+
Contract.id!(value[key], key)
|
|
83
|
+
end
|
|
84
|
+
unless value["parent_assignment_id"] == parent && value["parent_candidate_generation"].is_a?(Integer) &&
|
|
85
|
+
value["parent_candidate_generation"] >= 0
|
|
86
|
+
raise ArgumentError, "campaign execution parent differs"
|
|
87
|
+
end
|
|
88
|
+
%w[contract_identity policy_digest scope_identity_digest].each do |key|
|
|
89
|
+
unless value[key].is_a?(String) && /\A[0-9a-f]{64}\z/.match?(value[key])
|
|
90
|
+
raise ArgumentError, "campaign execution digest differs"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
%w[head base].each { |key| Contract.sha!(value[key], key) }
|
|
94
|
+
unless Contract.subject!(value["subject"]) == value["subject"]
|
|
95
|
+
raise ArgumentError, "campaign execution subject is not canonical"
|
|
96
|
+
end
|
|
97
|
+
policy = Contract.policy!(policy)
|
|
98
|
+
unless Contract.digest(policy) == value["policy_digest"] && policy["required_scopes"].include?(value["review_scope"])
|
|
99
|
+
raise ArgumentError, "campaign execution policy differs"
|
|
100
|
+
end
|
|
101
|
+
expected = case value["phase"]
|
|
102
|
+
when "collection" then ["review-collect", "review-execution"]
|
|
103
|
+
when "approval" then ["review", "review-approval"]
|
|
104
|
+
when "check"
|
|
105
|
+
check = value["check_name"]
|
|
106
|
+
if Molecules::ReceiptVerifier::EXTERNAL_EFFECT_OPERATIONS.include?(check)
|
|
107
|
+
raise ArgumentError, "external effects cannot be campaign checks"
|
|
108
|
+
end
|
|
109
|
+
raise ArgumentError, "campaign execution check is not required" unless policy["required_checks"].include?(check)
|
|
110
|
+
[check == "tests" ? "test" : check, check]
|
|
111
|
+
else raise ArgumentError, "campaign execution phase differs"
|
|
112
|
+
end
|
|
113
|
+
raise ArgumentError, "campaign execution operation differs" unless value.values_at("operation", "check_name") == expected
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "receipt_transfer"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
module CampaignRoundTransfer
|
|
10
|
+
def self.decode(input:, sha256:)
|
|
11
|
+
admitted = ReceiptTransfer.decode(input: input, receipt_sha256: sha256,
|
|
12
|
+
artifact_field: "artifacts", reference_key: "path")
|
|
13
|
+
text = input.bytes(index: 0).dup.force_encoding(Encoding::UTF_8)
|
|
14
|
+
raise ArgumentError, "round JSON encoding differs" unless text.valid_encoding? && !text.include?("\0")
|
|
15
|
+
envelope = JSON.parse(text, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
|
|
16
|
+
unless envelope.keys.sort == %w[artifacts round version] && envelope["version"].is_a?(Integer) && envelope["version"] == 1 &&
|
|
17
|
+
envelope["round"].is_a?(Hash) && envelope["artifacts"].all? { |ref| relative_path?(ref["path"]) }
|
|
18
|
+
raise ArgumentError, "round envelope differs"
|
|
19
|
+
end
|
|
20
|
+
{round: envelope.fetch("round"), references: envelope.fetch("artifacts"),
|
|
21
|
+
bytes: admitted.fetch(:artifacts), sha256: sha256}.freeze
|
|
22
|
+
rescue JSON::ParserError, KeyError, TypeError
|
|
23
|
+
raise ArgumentError, "round envelope is malformed"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.relative_path?(path)
|
|
27
|
+
path.is_a?(String) && path.encoding == Encoding::UTF_8 && path.valid_encoding? && path.bytesize.between?(1, 256) &&
|
|
28
|
+
!path.start_with?("/") && !path.include?("\\") && !path.include?("\0") &&
|
|
29
|
+
path.split("/", -1).none? { |component| component.empty? || %w[. ..].include?(component) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Paths are private materialization names, never source filesystem reads.
|
|
33
|
+
# The original R1 replay digest binds all supplied bytes independently.
|
|
34
|
+
def self.materialize(admitted:, repository:, request_id:)
|
|
35
|
+
base = File.join(repository, ".ace-local", "review", "campaign-inputs")
|
|
36
|
+
FileUtils.mkdir_p(base, mode: 0o700)
|
|
37
|
+
PrivateDirectory.verify!(base)
|
|
38
|
+
final = File.join(base, Digest::SHA256.hexdigest(request_id))
|
|
39
|
+
if File.exist?(final)
|
|
40
|
+
PrivateDirectory.verify!(final)
|
|
41
|
+
verify_materialization!(admitted, final)
|
|
42
|
+
else
|
|
43
|
+
temporary = Dir.mktmpdir("round-", base)
|
|
44
|
+
admitted.fetch(:references).zip(admitted.fetch(:bytes)).each do |reference, bytes|
|
|
45
|
+
path = File.join(temporary, reference.fetch("path"))
|
|
46
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
47
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
48
|
+
file.write(bytes)
|
|
49
|
+
file.flush
|
|
50
|
+
file.fsync
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
File.rename(temporary, final)
|
|
54
|
+
temporary = nil
|
|
55
|
+
File.open(base, File::RDONLY) { |directory| directory.fsync }
|
|
56
|
+
end
|
|
57
|
+
admitted.fetch(:references).to_h { |reference| [reference.fetch("path"), File.join(final, reference.fetch("path"))] }.freeze
|
|
58
|
+
ensure
|
|
59
|
+
FileUtils.remove_entry(temporary) if temporary && File.directory?(temporary)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.verify_materialization!(admitted, root)
|
|
63
|
+
expected = admitted.fetch(:references).map { |ref| ref.fetch("path") }.sort
|
|
64
|
+
actual = Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).reject { |path| File.directory?(path) }
|
|
65
|
+
.map { |path| path.delete_prefix(root + "/") }.sort
|
|
66
|
+
raise AttemptErrors::Conflict, "round materialization differs" unless expected == actual
|
|
67
|
+
admitted.fetch(:references).each do |reference|
|
|
68
|
+
path = File.join(root, reference.fetch("path"))
|
|
69
|
+
PrivateDirectory.verify!(File.dirname(path))
|
|
70
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
71
|
+
stat = file.stat
|
|
72
|
+
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o777) == 0o600 &&
|
|
73
|
+
stat.size <= 64 * 1024 && Digest::SHA256.hexdigest(file.read(64 * 1024 + 1)) == reference.fetch("sha256")
|
|
74
|
+
raise AttemptErrors::Conflict, "round materialization bytes differ"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|