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,627 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "digest"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require_relative "private_directory"
|
|
8
|
+
require_relative "posix_acl"
|
|
9
|
+
require_relative "task_context_entry"
|
|
10
|
+
require_relative "campaign_consumer_policy"
|
|
11
|
+
require "ace/runtime/molecules/protected_worker_entry"
|
|
12
|
+
require "ace/runtime/molecules/protected_socket"
|
|
13
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
14
|
+
require "ace/runtime/molecules/protected_linux"
|
|
15
|
+
require "ace/runtime/molecules/execution_unit_installation"
|
|
16
|
+
require "ace/runtime/molecules/readiness_configuration"
|
|
17
|
+
|
|
18
|
+
module Ace
|
|
19
|
+
module Assign
|
|
20
|
+
module Authority
|
|
21
|
+
class Deployment
|
|
22
|
+
PATH = "/etc/ace/assignment-authorities.json"
|
|
23
|
+
TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
24
|
+
attr_reader :data, :artifact_reference
|
|
25
|
+
|
|
26
|
+
def self.load
|
|
27
|
+
Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
|
|
28
|
+
bytes, reference = artifacts.read_path!(PATH, limit: 65_536)
|
|
29
|
+
deployment = from_verified_bytes(bytes, reference)
|
|
30
|
+
artifacts.verify_unchanged!
|
|
31
|
+
deployment
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Trusted installer source call only; this is not a transport selector.
|
|
36
|
+
# Authentication establishes fixed descriptor bytes, not installed readiness.
|
|
37
|
+
def self.load_artifact(reference)
|
|
38
|
+
unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
|
|
39
|
+
reference["path"].is_a?(String) && reference["path"].start_with?("/") &&
|
|
40
|
+
!reference["path"].include?("\0") && File.expand_path(reference["path"]) == reference["path"] &&
|
|
41
|
+
reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
42
|
+
reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536)
|
|
43
|
+
raise ArgumentError, "invalid protected deployment artifact reference"
|
|
44
|
+
end
|
|
45
|
+
selected = reference.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
46
|
+
Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
|
|
47
|
+
deployment = from_verified_bytes(artifacts.read!(selected), selected)
|
|
48
|
+
artifacts.verify_unchanged!
|
|
49
|
+
deployment
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.from_verified_bytes(raw, reference)
|
|
54
|
+
bytes = raw.dup.force_encoding(Encoding::UTF_8)
|
|
55
|
+
raise ArgumentError, "deployment artifact is not UTF-8" unless bytes.valid_encoding?
|
|
56
|
+
raise ArgumentError, "deployment artifact exceeds bounds" unless bytes.bytesize.between?(1, 65_536)
|
|
57
|
+
value = JSON.parse(bytes, create_additions: false, max_nesting: 32,
|
|
58
|
+
allow_duplicate_key: false, allow_comments: false)
|
|
59
|
+
deployment = new(value)
|
|
60
|
+
deployment.send(:freeze_data!, deployment.data)
|
|
61
|
+
deployment.instance_variable_set(:@artifact_reference, reference)
|
|
62
|
+
deployment.freeze
|
|
63
|
+
end
|
|
64
|
+
private_class_method :from_verified_bytes
|
|
65
|
+
|
|
66
|
+
def initialize(data)
|
|
67
|
+
unless data.is_a?(Hash) && data["schema"] == "ace.assign.authorities/v2" &&
|
|
68
|
+
data.keys.sort == %w[authorities launch_mappings projects schema]
|
|
69
|
+
raise ArgumentError, "invalid assignment authority deployment schema"
|
|
70
|
+
end
|
|
71
|
+
%w[authorities launch_mappings projects].each do |section|
|
|
72
|
+
unless data[section].is_a?(Hash) && data[section].keys.all? { |id| TOKEN.match?(id) }
|
|
73
|
+
raise ArgumentError, "invalid #{section} mapping"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
@data = JSON.parse(JSON.generate(data))
|
|
77
|
+
@data["authorities"].each_value do |service|
|
|
78
|
+
strict!(service, %w[uid gid groups socket_path state_root composition])
|
|
79
|
+
unless %w[launch services].include?(service["composition"])
|
|
80
|
+
raise ArgumentError, "unknown installed authority composition"
|
|
81
|
+
end
|
|
82
|
+
principal!(service, "uid", "gid", "groups")
|
|
83
|
+
%w[socket_path state_root].each { |key| path!(service.fetch(key)) }
|
|
84
|
+
end
|
|
85
|
+
@data["projects"].each_value do |project|
|
|
86
|
+
raise ArgumentError, "invalid project mapping" unless project.is_a?(Hash)
|
|
87
|
+
keys = %w[journal_repository evidence_git_ref evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root campaign_policy
|
|
88
|
+
launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids peer_credentials]
|
|
89
|
+
keys << "service_receivers" if project.key?("service_receivers")
|
|
90
|
+
keys << "inbox_contexts" if project.key?("inbox_contexts")
|
|
91
|
+
strict!(project, keys)
|
|
92
|
+
CampaignConsumerPolicy.reference!(project.fetch("campaign_policy"))
|
|
93
|
+
%w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].each { |key| path!(project.fetch(key)) }
|
|
94
|
+
unless project["evidence_git_ref"] == "refs/ace/execution"
|
|
95
|
+
raise ArgumentError, "protected authority uses the canonical execution ref"
|
|
96
|
+
end
|
|
97
|
+
role_keys = %w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids]
|
|
98
|
+
role_uids = role_keys.flat_map do |key|
|
|
99
|
+
values = project.fetch(key)
|
|
100
|
+
unless values.is_a?(Array) && values.all? { |id| id.is_a?(Integer) && id.positive? } && values == values.sort.uniq
|
|
101
|
+
raise ArgumentError, "invalid project principal allowlist"
|
|
102
|
+
end
|
|
103
|
+
values
|
|
104
|
+
end.uniq.sort
|
|
105
|
+
peers = project.fetch("peer_credentials")
|
|
106
|
+
unless peers.is_a?(Hash) && peers.keys.sort == role_uids.map(&:to_s).sort
|
|
107
|
+
raise ArgumentError, "fixed project peer credentials are incomplete"
|
|
108
|
+
end
|
|
109
|
+
peers.each do |uid, peer|
|
|
110
|
+
strict!(peer, %w[gid groups scratch_root])
|
|
111
|
+
principal!(peer.merge("uid" => Integer(uid, 10)), "uid", "gid", "groups")
|
|
112
|
+
path!(peer.fetch("scratch_root"))
|
|
113
|
+
end
|
|
114
|
+
receivers = project.fetch("service_receivers", {})
|
|
115
|
+
unless receivers.is_a?(Hash) && receivers.keys.all? { |id| id.is_a?(String) && TOKEN.match?(id) }
|
|
116
|
+
raise ArgumentError, "invalid installed service receiver mapping"
|
|
117
|
+
end
|
|
118
|
+
receivers.each_value do |receiver|
|
|
119
|
+
strict!(receiver, %w[executor_uid socket_path staging_root])
|
|
120
|
+
uid = receiver.fetch("executor_uid")
|
|
121
|
+
conflicting = %w[launcher_uids reviewer_uids worker_uids supervisor_uids].flat_map { |key| project.fetch(key) }
|
|
122
|
+
unless uid.is_a?(Integer) && uid.positive? && project.fetch("service_executor_uids").include?(uid) && !conflicting.include?(uid)
|
|
123
|
+
raise ArgumentError, "receiver requires an unambiguous mapped executor"
|
|
124
|
+
end
|
|
125
|
+
%w[socket_path staging_root].each { |key| path!(receiver.fetch(key)) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
@data["launch_mappings"].each { |id, mapping| validate_mapping!(id, mapping) }
|
|
129
|
+
validate_execution_slots!
|
|
130
|
+
validate_inbox_contexts!
|
|
131
|
+
endpoints = @data.fetch("authorities").values.map { |service| service.fetch("socket_path") }
|
|
132
|
+
receivers = @data.fetch("projects").values.flat_map { |project| project.fetch("service_receivers", {}).values }
|
|
133
|
+
protected_uids = @data.fetch("authorities").values.map { |service| service.fetch("uid") } +
|
|
134
|
+
@data.fetch("projects").values.flat_map { |project| %w[launcher_uids reviewer_uids worker_uids supervisor_uids].flat_map { |key| project.fetch(key) } }
|
|
135
|
+
if receivers.any? { |receiver| protected_uids.include?(receiver.fetch("executor_uid")) }
|
|
136
|
+
raise ArgumentError, "receiver executor overlaps another installed role"
|
|
137
|
+
end
|
|
138
|
+
endpoints.concat(receivers.map { |receiver| receiver.fetch("socket_path") })
|
|
139
|
+
raise ArgumentError, "authority endpoint has duplicate owners" unless endpoints.uniq.size == endpoints.size
|
|
140
|
+
staging = receivers.map { |receiver| receiver.fetch("staging_root") }
|
|
141
|
+
unless staging.combination(2).none? { |left, right| paths_overlap?(left, right) }
|
|
142
|
+
raise ArgumentError, "receiver staging roots overlap"
|
|
143
|
+
end
|
|
144
|
+
if receivers.any? { |receiver| staging.any? { |root| paths_overlap?(receiver.fetch("socket_path"), root) } }
|
|
145
|
+
raise ArgumentError, "receiver endpoint must be separate from private staging"
|
|
146
|
+
end
|
|
147
|
+
protected_roots = @data.fetch("authorities").values.map { |service| service.fetch("state_root") } +
|
|
148
|
+
@data.fetch("projects").values.flat_map { |project| %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| project.fetch(key) } }
|
|
149
|
+
if staging.any? { |root| protected_roots.any? { |protected| paths_overlap?(root, protected) } }
|
|
150
|
+
raise ArgumentError, "receiver staging overlaps authority state"
|
|
151
|
+
end
|
|
152
|
+
campaign_roots = @data.fetch("projects").values.flat_map { |project| project.values_at("campaign_repository", "campaign_store_root") }
|
|
153
|
+
other_roots = @data.fetch("authorities").values.map { |service| service.fetch("state_root") } +
|
|
154
|
+
@data.fetch("projects").values.flat_map { |project|
|
|
155
|
+
project.values_at("journal_repository", "evidence_checkout_root", "assignment_root", "candidate_root") +
|
|
156
|
+
project.fetch("peer_credentials").values.map { |peer| peer.fetch("scratch_root") } }
|
|
157
|
+
if campaign_roots.combination(2).any? { |left, right| paths_overlap?(left, right) } ||
|
|
158
|
+
campaign_roots.any? { |root| other_roots.any? { |other| paths_overlap?(root, other) } }
|
|
159
|
+
raise ArgumentError, "campaign roots overlap another protected or worker root"
|
|
160
|
+
end
|
|
161
|
+
repositories = @data.fetch("projects").values.map { |project| project.fetch("journal_repository") }
|
|
162
|
+
raise ArgumentError, "canonical project repository has duplicate mappings" unless repositories.uniq.size == repositories.size
|
|
163
|
+
@data.fetch("projects").each_key do |project_id|
|
|
164
|
+
owners = @data.fetch("launch_mappings").values.select { |map| map["project_id"] == project_id }.map { |map| map.fetch("authority_id") }.uniq
|
|
165
|
+
raise ArgumentError, "project must have exactly one authority owner" unless owners.size == 1
|
|
166
|
+
service = authority(owners.first)
|
|
167
|
+
installed = project(project_id).fetch("service_receivers", {})
|
|
168
|
+
if service.fetch("composition") == "services" && installed.empty?
|
|
169
|
+
raise ArgumentError, "services composition requires installed receivers"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Static selection only. Live native identity comes from the attempt's
|
|
175
|
+
# canonical scope stage, never from this deployment record.
|
|
176
|
+
def inbox_context(mapping_id, context_id)
|
|
177
|
+
map = mapping(mapping_id)
|
|
178
|
+
contexts = project(map.fetch("project_id")).fetch("inbox_contexts", {})
|
|
179
|
+
context = contexts.fetch(context_id) { raise AttemptErrors::EvidenceUnavailable, "installed inbox context is unavailable" }
|
|
180
|
+
native = mapping(context.fetch("native_mapping_id"))
|
|
181
|
+
unless native.fetch("project_id") == map.fetch("project_id") && native.fetch("authority_id") == map.fetch("authority_id")
|
|
182
|
+
raise AttemptErrors::UnauthorizedIdentity, "inbox context belongs to another authority"
|
|
183
|
+
end
|
|
184
|
+
JSON.parse(JSON.generate(context))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def verify_inbox_context!(mapping_id, context_id)
|
|
188
|
+
context = inbox_context(mapping_id, context_id)
|
|
189
|
+
service = authority(mapping(mapping_id).fetch("authority_id"))
|
|
190
|
+
unless Process.uid == service.fetch("uid")
|
|
191
|
+
raise Ace::Runtime::RuntimeUnavailableError, "inbox resolver must run as its installed authority"
|
|
192
|
+
end
|
|
193
|
+
credentials = context.fetch("owner_credentials")
|
|
194
|
+
path = context.fetch("control_socket_path")
|
|
195
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
196
|
+
wire.root_path!(File.dirname(path), directory: true, owner: credentials.fetch("uid"))
|
|
197
|
+
identity = wire.socket_identity(path)
|
|
198
|
+
unless identity.last == credentials.fetch("uid")
|
|
199
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed context owner endpoint differs"
|
|
200
|
+
end
|
|
201
|
+
context
|
|
202
|
+
rescue AttemptErrors::ReceiptRejected, OpenSSL::PKey::PKeyError, SystemCallError
|
|
203
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed inbox context evidence is unavailable"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def verify_inbox_acl!(path, private_leaf: false)
|
|
207
|
+
current = path
|
|
208
|
+
loop do
|
|
209
|
+
rows = receiver_acl.entries(current)
|
|
210
|
+
if rows
|
|
211
|
+
mask = rows.find { |tag, _perm, _id| tag == 16 }&.at(1) || 7
|
|
212
|
+
allowed = current == path && !private_leaf ? [0] : [0, Process.uid]
|
|
213
|
+
unsafe = rows.any? do |tag, perm, uid|
|
|
214
|
+
effective = [2, 4, 8].include?(tag) ? perm & mask : perm
|
|
215
|
+
case tag
|
|
216
|
+
when 2 then !allowed.include?(uid) && (effective & 2).positive?
|
|
217
|
+
when 4, 8, 32 then (effective & 2).positive?
|
|
218
|
+
else false
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
if private_leaf && current == path
|
|
222
|
+
unsafe ||= rows.any? do |tag, perm, uid|
|
|
223
|
+
effective = [2, 4, 8].include?(tag) ? perm & mask : perm
|
|
224
|
+
[4, 8, 32].include?(tag) && effective.positive? || tag == 2 && uid != Process.uid && effective.positive?
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed inbox access ACL is unsafe" if unsafe
|
|
228
|
+
end
|
|
229
|
+
break if current == "/"
|
|
230
|
+
current = File.dirname(current)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
private :verify_inbox_acl!
|
|
234
|
+
|
|
235
|
+
def validate_inbox_contexts!
|
|
236
|
+
roots = []
|
|
237
|
+
service_scopes = data.fetch("launch_mappings").values.map { |map| map.fetch("execution_scope") }
|
|
238
|
+
service_owner_uids = []
|
|
239
|
+
data.fetch("projects").each do |project_id, fixed_project|
|
|
240
|
+
contexts = fixed_project.fetch("inbox_contexts", {})
|
|
241
|
+
unless contexts.is_a?(Hash) && contexts.keys.all? { |id| id.is_a?(String) && TOKEN.match?(id) }
|
|
242
|
+
raise ArgumentError, "invalid installed inbox context map"
|
|
243
|
+
end
|
|
244
|
+
contexts.each_value do |context|
|
|
245
|
+
fields = %w[control_socket_path deliveries_dir owner_credentials native_mapping_id pi_queue_client pi_queue_client_sha256 supervisor_uids]
|
|
246
|
+
fields << "service" if context.key?("service")
|
|
247
|
+
strict!(context, fields)
|
|
248
|
+
credentials = context.fetch("owner_credentials")
|
|
249
|
+
strict!(credentials, %w[gid groups uid])
|
|
250
|
+
principal!(credentials, "uid", "gid", "groups")
|
|
251
|
+
raise ArgumentError, "context owner group count exceeds bounds" if credentials.fetch("groups").size > 64
|
|
252
|
+
%w[control_socket_path deliveries_dir pi_queue_client].each { |key| path!(context.fetch(key)) }
|
|
253
|
+
unless context["pi_queue_client_sha256"].is_a?(String) && context["pi_queue_client_sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
254
|
+
raise ArgumentError, "invalid installed Pi identity client digest"
|
|
255
|
+
end
|
|
256
|
+
supervisors = context.fetch("supervisor_uids")
|
|
257
|
+
unless supervisors.is_a?(Array) && supervisors.all? { |uid| uid.is_a?(Integer) && uid.positive? } &&
|
|
258
|
+
supervisors == supervisors.sort.uniq && (supervisors - fixed_project.fetch("supervisor_uids")).empty?
|
|
259
|
+
raise ArgumentError, "inbox supervisors exceed installed project authority"
|
|
260
|
+
end
|
|
261
|
+
id = context.fetch("native_mapping_id")
|
|
262
|
+
unless id.is_a?(String) && TOKEN.match?(id) && data.fetch("launch_mappings").key?(id)
|
|
263
|
+
raise ArgumentError, "inbox native mapping is unavailable"
|
|
264
|
+
end
|
|
265
|
+
native = mapping(id)
|
|
266
|
+
unless native.fetch("project_id") == project_id && authority(native.fetch("authority_id")).fetch("composition") == "services"
|
|
267
|
+
raise ArgumentError, "inbox context requires its services project mapping"
|
|
268
|
+
end
|
|
269
|
+
if context.key?("service")
|
|
270
|
+
Ace::Runtime::Molecules::ExecutionUnitInstallation.validate_inbox_context_service!(context.fetch("service"))
|
|
271
|
+
scope = context.fetch("service").fetch("execution_scope")
|
|
272
|
+
if service_scopes.any? { |other|
|
|
273
|
+
%w[slot_id slice_unit service_unit].any? { |field| scope.fetch(field) == other.fetch(field) } ||
|
|
274
|
+
scope.values_at("root_directory", "runtime_directory").any? { |path|
|
|
275
|
+
other.values_at("root_directory", "runtime_directory").any? { |existing| paths_overlap?(path, existing) }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
raise ArgumentError, "context service shares a protected execution slot or backing root"
|
|
279
|
+
end
|
|
280
|
+
prohibited_uids = data.fetch("authorities").values.map { |service| service.fetch("uid") } +
|
|
281
|
+
data.fetch("launch_mappings").values.flat_map { |map| map.values_at("launcher_uid", "worker_uid") } +
|
|
282
|
+
data.fetch("projects").values.flat_map { |project|
|
|
283
|
+
%w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids].flat_map { |field| project.fetch(field) } +
|
|
284
|
+
project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("executor_uid") }
|
|
285
|
+
}
|
|
286
|
+
prohibited_uids.concat(data.fetch("projects").values.flat_map { |project|
|
|
287
|
+
project.fetch("inbox_contexts", {}).values.reject { |other| other.equal?(context) }.map { |other| other.fetch("owner_credentials").fetch("uid") }
|
|
288
|
+
})
|
|
289
|
+
uid = credentials.fetch("uid")
|
|
290
|
+
if prohibited_uids.include?(uid) || service_owner_uids.include?(uid)
|
|
291
|
+
raise ArgumentError, "context service owner shares another protected principal"
|
|
292
|
+
end
|
|
293
|
+
service_owner_uids << uid
|
|
294
|
+
service_scopes << scope
|
|
295
|
+
end
|
|
296
|
+
roots << context.fetch("deliveries_dir")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
forbidden = data.fetch("projects").values.flat_map do |fixed_project|
|
|
300
|
+
fixed_project.fetch("peer_credentials").values.map { |peer| peer.fetch("scratch_root") } +
|
|
301
|
+
fixed_project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("staging_root") } +
|
|
302
|
+
%w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| fixed_project.fetch(key) }
|
|
303
|
+
end
|
|
304
|
+
forbidden.concat(data.fetch("authorities").values.map { |service| service.fetch("state_root") })
|
|
305
|
+
if roots.combination(2).any? { |left, right| paths_overlap?(left, right) } ||
|
|
306
|
+
roots.any? { |root| forbidden.any? { |other| paths_overlap?(root, other) } }
|
|
307
|
+
raise ArgumentError, "installed inbox private roots overlap protected state"
|
|
308
|
+
end
|
|
309
|
+
rescue Ace::Runtime::RuntimeUnavailableError
|
|
310
|
+
raise ArgumentError, "invalid context service installation selection"
|
|
311
|
+
end
|
|
312
|
+
private :validate_inbox_contexts!
|
|
313
|
+
|
|
314
|
+
# Validate fixed receiver placement before the owner opens a listener or
|
|
315
|
+
# creates journal state. Operations/grants remain the Lab policy's job.
|
|
316
|
+
def verify_receiver_paths!(authority_id)
|
|
317
|
+
projects = data.fetch("launch_mappings").values.select { |map| map["authority_id"] == authority_id }.map { |map| map.fetch("project_id") }.uniq
|
|
318
|
+
projects.each do |id|
|
|
319
|
+
project(id).fetch("service_receivers", {}).each_value do |receiver|
|
|
320
|
+
uid = receiver.fetch("executor_uid")
|
|
321
|
+
root = receiver.fetch("staging_root")
|
|
322
|
+
credentials = project(id).fetch("peer_credentials").fetch(uid.to_s)
|
|
323
|
+
groups = (credentials.fetch("groups") + [credentials.fetch("gid")]).uniq
|
|
324
|
+
receiver_directory!(root, uid: uid, groups: groups)
|
|
325
|
+
unless File.executable?(File.dirname(root))
|
|
326
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority cannot inspect receiver staging"
|
|
327
|
+
end
|
|
328
|
+
stat = File.lstat(root)
|
|
329
|
+
unless stat.uid == uid && (stat.mode & 0o7777) == 0o700
|
|
330
|
+
raise Ace::Runtime::RuntimeUnavailableError, "receiver staging must be executor-private"
|
|
331
|
+
end
|
|
332
|
+
path = receiver.fetch("socket_path")
|
|
333
|
+
receiver_directory!(File.dirname(path), uid: uid, groups: groups)
|
|
334
|
+
unless File.executable?(File.dirname(path))
|
|
335
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority cannot inspect receiver endpoint"
|
|
336
|
+
end
|
|
337
|
+
if File.exist?(path) || File.symlink?(path)
|
|
338
|
+
endpoint = File.lstat(path)
|
|
339
|
+
unless endpoint.socket? && !endpoint.symlink? && endpoint.uid == uid &&
|
|
340
|
+
endpoint.gid == project(id).fetch("peer_credentials").fetch(uid.to_s).fetch("gid") && (endpoint.mode & 0o007).zero?
|
|
341
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed receiver endpoint is unsafe"
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
true
|
|
347
|
+
rescue SystemCallError
|
|
348
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed receiver paths are unavailable"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def receiver_directory!(path, uid:, groups:)
|
|
352
|
+
current = path
|
|
353
|
+
loop do
|
|
354
|
+
stat = File.lstat(current)
|
|
355
|
+
unless stat.directory? && !stat.symlink? && [0, uid].include?(stat.uid) && (stat.mode & 0o022).zero?
|
|
356
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed receiver ancestry is unsafe"
|
|
357
|
+
end
|
|
358
|
+
unless receiver_acl.searchable?(current, stat: stat, uid: uid, groups: groups)
|
|
359
|
+
raise Ace::Runtime::RuntimeUnavailableError, "executor cannot traverse receiver ancestry"
|
|
360
|
+
end
|
|
361
|
+
break if current == "/"
|
|
362
|
+
current = File.dirname(current)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
private :receiver_directory!
|
|
366
|
+
|
|
367
|
+
def receiver_acl
|
|
368
|
+
@receiver_acl ||= PosixAcl.new
|
|
369
|
+
end
|
|
370
|
+
private :receiver_acl
|
|
371
|
+
|
|
372
|
+
def paths_overlap?(left, right)
|
|
373
|
+
left == right || left.start_with?(right.chomp("/") + "/") || right.start_with?(left.chomp("/") + "/")
|
|
374
|
+
end
|
|
375
|
+
private :paths_overlap?
|
|
376
|
+
|
|
377
|
+
def verify_composition!(authority_id, composition:)
|
|
378
|
+
unless %w[launch services].include?(composition) && authority(authority_id).fetch("composition") == composition
|
|
379
|
+
raise ArgumentError, "installed authority composition differs from source entrypoint"
|
|
380
|
+
end
|
|
381
|
+
true
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def authority(id)
|
|
385
|
+
data.fetch("authorities").fetch(id)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def project(id)
|
|
389
|
+
data.fetch("projects").fetch(id)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def mapping(id)
|
|
393
|
+
data.fetch("launch_mappings").fetch(id)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Canonical mapping identity shared by scope evidence and the trusted
|
|
397
|
+
# provisioning owner. Resolves only this descriptor's exact mapping ID.
|
|
398
|
+
def mapping_digest(id)
|
|
399
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical_mapping_value(mapping(id)))).freeze
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def canonical_mapping_value(value)
|
|
403
|
+
case value
|
|
404
|
+
when Hash then value.sort.to_h.transform_values { |item| canonical_mapping_value(item) }
|
|
405
|
+
when Array then value.map { |item| canonical_mapping_value(item) }
|
|
406
|
+
else value
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
private :canonical_mapping_value
|
|
410
|
+
|
|
411
|
+
def validate_mapping!(id, mapping)
|
|
412
|
+
required = %w[project_id authority_id launcher_uid launcher_gid launcher_groups worker_uid worker_gid
|
|
413
|
+
worker_groups worker_actor worker_cwd worker_entry worker_env bootstrap bootstrap_sha256 native execution_scope task_context_entry]
|
|
414
|
+
workspace = %w[workspace_repository_id workspace_cleanup_config]
|
|
415
|
+
unless mapping.is_a?(Hash) && [required.sort, (required + workspace).sort].include?(mapping.keys.sort)
|
|
416
|
+
raise ArgumentError, "launch mapping fields differ: #{id}"
|
|
417
|
+
end
|
|
418
|
+
if mapping.key?("workspace_repository_id")
|
|
419
|
+
repository = mapping.fetch("workspace_repository_id")
|
|
420
|
+
unless repository.is_a?(String) && TOKEN.match?(repository)
|
|
421
|
+
raise ArgumentError, "workspace repository identity is invalid"
|
|
422
|
+
end
|
|
423
|
+
reference = mapping.fetch("workspace_cleanup_config")
|
|
424
|
+
strict!(reference, %w[path sha256 bytes])
|
|
425
|
+
path!(reference.fetch("path"))
|
|
426
|
+
unless reference.fetch("path").bytesize <= 4096 && reference.fetch("path").encoding == Encoding::UTF_8 && reference.fetch("path").valid_encoding? &&
|
|
427
|
+
digest?(reference.fetch("sha256")) && reference.fetch("bytes").is_a?(Integer) && reference.fetch("bytes").between?(1, 65_536)
|
|
428
|
+
raise ArgumentError, "workspace cleanup configuration reference is invalid"
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
TaskContextEntry.validate!(mapping.fetch("task_context_entry"))
|
|
432
|
+
Ace::Runtime::Molecules::ProtectedWorkerEntry.validate!(mapping.fetch("worker_entry"))
|
|
433
|
+
service = authority(mapping.fetch("authority_id"))
|
|
434
|
+
fixed_project = project(mapping.fetch("project_id"))
|
|
435
|
+
%w[launcher worker].each do |role|
|
|
436
|
+
uid = mapping.fetch("#{role}_uid")
|
|
437
|
+
expected = fixed_project.fetch("peer_credentials").fetch(uid.to_s)
|
|
438
|
+
unless fixed_project.fetch("#{role}_uids").include?(uid) && expected["gid"] == mapping["#{role}_gid"] && expected["groups"] == mapping["#{role}_groups"]
|
|
439
|
+
raise ArgumentError, "launch credentials differ from project allowlist"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
uids = [service.fetch("uid"), mapping.fetch("launcher_uid"), mapping.fetch("worker_uid")]
|
|
443
|
+
unless uids.uniq.size == 3 && uids.all? { |uid| uid.is_a?(Integer) && uid.positive? }
|
|
444
|
+
raise ArgumentError, "protected launch requires distinct non-root principals"
|
|
445
|
+
end
|
|
446
|
+
%w[launcher worker].each do |role|
|
|
447
|
+
principal!(mapping, "#{role}_uid", "#{role}_gid", "#{role}_groups")
|
|
448
|
+
end
|
|
449
|
+
%w[worker_cwd bootstrap].each { |key| path!(mapping.fetch(key)) }
|
|
450
|
+
native = mapping.fetch("native")
|
|
451
|
+
strict!(native, %w[socket_path executable executable_sha256 version protocol workspace_id])
|
|
452
|
+
%w[socket_path executable].each { |key| path!(native.fetch(key)) }
|
|
453
|
+
unless native["workspace_id"].is_a?(String) && native["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/) &&
|
|
454
|
+
native["version"] == "0.9.3" && native["protocol"] == 22 && digest?(native["executable_sha256"])
|
|
455
|
+
raise ArgumentError, "invalid fixed native artifact or protocol"
|
|
456
|
+
end
|
|
457
|
+
scope = mapping.fetch("execution_scope")
|
|
458
|
+
strict!(scope, %w[backend slot_id slice_unit service_unit unit_manifest_sha256 boundary_manifest_sha256
|
|
459
|
+
root_directory runtime_directory network_namespace_path])
|
|
460
|
+
unless scope["backend"] == "linux_systemd_cgroup_v2" && scope["slot_id"].is_a?(String) && TOKEN.match?(scope["slot_id"]) &&
|
|
461
|
+
%w[unit_manifest_sha256 boundary_manifest_sha256].all? { |key| digest?(scope[key]) }
|
|
462
|
+
raise ArgumentError, "invalid execution scope backend or manifest identity"
|
|
463
|
+
end
|
|
464
|
+
{"slice_unit" => ".slice", "service_unit" => ".service"}.each do |key, suffix|
|
|
465
|
+
name = scope.fetch(key)
|
|
466
|
+
unless name.is_a?(String) && TOKEN.match?(name) && name.end_with?(suffix) &&
|
|
467
|
+
name != "-.slice" && !name.downcase.include?("overseer")
|
|
468
|
+
raise ArgumentError, "invalid fixed protected unit"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
%w[root_directory runtime_directory network_namespace_path].each { |key| path!(scope.fetch(key)) }
|
|
472
|
+
unless scope.fetch("runtime_directory").start_with?("/run/") &&
|
|
473
|
+
!paths_overlap?(scope.fetch("root_directory"), scope.fetch("runtime_directory"))
|
|
474
|
+
raise ArgumentError, "execution scope backing roots overlap or runtime root is invalid"
|
|
475
|
+
end
|
|
476
|
+
env = mapping.fetch("worker_env")
|
|
477
|
+
unless env.is_a?(Hash) && env.all? { |k, v| k.match?(/\A[A-Z_][A-Z0-9_]*\z/) && v.is_a?(String) && !v.include?("\0") } &&
|
|
478
|
+
env.keys.none? { |key| key.match?(/\A(?:LD_|DYLD_|RUBY|BUNDLE|PYTHON)/) }
|
|
479
|
+
raise ArgumentError, "invalid fixed worker environment"
|
|
480
|
+
end
|
|
481
|
+
unless mapping["worker_actor"].is_a?(String) && !mapping["worker_actor"].empty? &&
|
|
482
|
+
mapping["bootstrap_sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
483
|
+
raise ArgumentError, "invalid installed bootstrap or worker actor"
|
|
484
|
+
end
|
|
485
|
+
rescue KeyError, NoMethodError
|
|
486
|
+
raise ArgumentError, "launch mapping is incomplete"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def digest?(value)
|
|
490
|
+
value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def validate_execution_slots!
|
|
494
|
+
mappings = @data.fetch("launch_mappings").values
|
|
495
|
+
%w[slot_id slice_unit service_unit].each do |key|
|
|
496
|
+
values = mappings.map { |map| map.fetch("execution_scope").fetch(key) }
|
|
497
|
+
raise ArgumentError, "execution slot has duplicate ownership" unless values.uniq.size == values.size
|
|
498
|
+
end
|
|
499
|
+
workers = mappings.map { |map| map.fetch("worker_uid") }
|
|
500
|
+
raise ArgumentError, "protected execution slots share a worker principal" unless workers.uniq.size == workers.size
|
|
501
|
+
roots = mappings.map { |map| map.fetch("execution_scope").values_at("root_directory", "runtime_directory") }
|
|
502
|
+
roots.combination(2).each do |left, right|
|
|
503
|
+
if left.product(right).any? { |a, b| paths_overlap?(a, b) }
|
|
504
|
+
raise ArgumentError, "protected execution slot roots overlap"
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Original ownership cannot be recycled or hidden by a staged descriptor.
|
|
510
|
+
def maintenance_inventory(candidate)
|
|
511
|
+
unless candidate.is_a?(Deployment) && artifact_reference && candidate.artifact_reference && frozen? && candidate.frozen?
|
|
512
|
+
raise AttemptErrors::EvidenceUnavailable, "maintenance requires authenticated immutable original and candidate descriptors"
|
|
513
|
+
end
|
|
514
|
+
originals = data.fetch("launch_mappings")
|
|
515
|
+
proposed = candidate.data.fetch("launch_mappings")
|
|
516
|
+
originals.each do |id, map|
|
|
517
|
+
next unless proposed.key?(id)
|
|
518
|
+
unless maintenance_association(map) == candidate.maintenance_association(proposed.fetch(id))
|
|
519
|
+
raise ArgumentError, "existing maintenance mapping was reassociated: #{id}"
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
entries = originals.map { |id, map| [id, self, map] } +
|
|
523
|
+
proposed.reject { |id, _| originals.key?(id) }.map { |id, map| [id, candidate, map] }
|
|
524
|
+
all = originals.map { |id, map| [id, self, map] } + proposed.map { |id, map| [id, candidate, map] }
|
|
525
|
+
all.combination(2).each do |left, right|
|
|
526
|
+
l_id, l_owner, l_map = left
|
|
527
|
+
r_id, r_owner, r_map = right
|
|
528
|
+
l_scope, r_scope = l_map.fetch("execution_scope"), r_map.fetch("execution_scope")
|
|
529
|
+
shared = %w[slot_id slice_unit service_unit network_namespace_path].any? { |key| l_scope.fetch(key) == r_scope.fetch(key) }
|
|
530
|
+
shared ||= l_scope.values_at("root_directory", "runtime_directory").product(
|
|
531
|
+
r_scope.values_at("root_directory", "runtime_directory")).any? { |a, b| paths_overlap?(a, b) }
|
|
532
|
+
if shared && (l_id != r_id || l_owner.maintenance_association(l_map) != r_owner.maintenance_association(r_map))
|
|
533
|
+
raise ArgumentError, "physical execution slot has conflicting maintenance ownership"
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
entries.sort_by(&:first).map { |entry| entry.freeze }.freeze
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def maintenance_association(map)
|
|
540
|
+
fixed = project(map.fetch("project_id"))
|
|
541
|
+
[map.fetch("project_id"), fixed.values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root"),
|
|
542
|
+
authority(map.fetch("authority_id")).fetch("state_root"),
|
|
543
|
+
map.fetch("execution_scope").values_at("slot_id", "slice_unit", "service_unit", "network_namespace_path")]
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def freeze_data!(value)
|
|
547
|
+
case value
|
|
548
|
+
when Hash then value.each { |key, item| key.freeze; freeze_data!(item) }
|
|
549
|
+
when Array then value.each { |item| freeze_data!(item) }
|
|
550
|
+
end
|
|
551
|
+
value.freeze
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def strict!(value, keys)
|
|
555
|
+
raise ArgumentError, "deployment fields differ" unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def path!(value)
|
|
559
|
+
unless value.is_a?(String) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
|
|
560
|
+
raise ArgumentError, "deployment path must be fixed and absolute"
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def principal!(value, uid_key, gid_key, groups_key)
|
|
565
|
+
uid, gid, groups = value.values_at(uid_key, gid_key, groups_key)
|
|
566
|
+
unless [uid, gid].all? { |id| id.is_a?(Integer) && id.positive? } && groups.is_a?(Array) &&
|
|
567
|
+
groups.all? { |id| id.is_a?(Integer) && id.positive? } && groups == groups.sort.uniq
|
|
568
|
+
raise ArgumentError, "invalid principal credentials"
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def elf_architecture?(path)
|
|
573
|
+
header = File.binread(path, 20)
|
|
574
|
+
expected = {"x86_64" => 62, "aarch64" => 183, "arm64" => 183}[RbConfig::CONFIG.fetch("host_cpu")]
|
|
575
|
+
expected && header.bytesize == 20 && header.byteslice(0, 4) == "\x7fELF".b &&
|
|
576
|
+
header.getbyte(4) == 2 && header.getbyte(5) == 1 && header.getbyte(6) == 1 &&
|
|
577
|
+
header.byteslice(18, 2).unpack1("v") == expected
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def verify!(id, kernel: Ace::Runtime::Molecules::ProtectedLinux.new, authority_state: false, manager: nil)
|
|
581
|
+
kernel.supported!
|
|
582
|
+
map = mapping(id)
|
|
583
|
+
service = authority(map.fetch("authority_id"))
|
|
584
|
+
[service.fetch("uid"), map.fetch("launcher_uid"), map.fetch("worker_uid")].each { |uid| Etc.getpwuid(uid) }
|
|
585
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
586
|
+
scope = map.fetch("execution_scope")
|
|
587
|
+
manager ||= Ace::Runtime::Molecules::SystemdScopeManager.new(
|
|
588
|
+
slice_unit: scope.fetch("slice_unit"), service_unit: scope.fetch("service_unit"))
|
|
589
|
+
manifest = Ace::Runtime::Molecules::ExecutionUnitInstallation.new(scope: scope, native: map.fetch("native"),
|
|
590
|
+
bootstrap: map.fetch("bootstrap"), worker_entry: map.fetch("worker_entry"),
|
|
591
|
+
worker_uid: map.fetch("worker_uid"), worker_gid: map.fetch("worker_gid")).verify!(manager: manager)
|
|
592
|
+
artifacts = manifest.fetch("artifacts")
|
|
593
|
+
configuration = artifacts.find { |artifact| artifact.fetch("role") == "readiness_configuration" }
|
|
594
|
+
Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |reader|
|
|
595
|
+
bytes, = reader.read_path!(configuration.fetch("host_path"), limit: 65_536)
|
|
596
|
+
unless Digest::SHA256.hexdigest(bytes) == configuration.fetch("sha256")
|
|
597
|
+
raise Ace::Runtime::RuntimeUnavailableError, "readiness configuration content differs"
|
|
598
|
+
end
|
|
599
|
+
Ace::Runtime::Molecules::ReadinessConfiguration.decode(bytes, slot: scope.fetch("slot_id"))
|
|
600
|
+
.verify_selection!(mapping_id: id, mapping: map, authority: service, installation: manifest)
|
|
601
|
+
reader.verify_unchanged!
|
|
602
|
+
end
|
|
603
|
+
bootstrap = artifacts.find { |artifact| artifact.fetch("role") == "bootstrap" }
|
|
604
|
+
executables = artifacts.select do |artifact|
|
|
605
|
+
artifact.fetch("role") == "bootstrap" || (artifact.fetch("role") == "runtime_dependency" &&
|
|
606
|
+
artifact.fetch("view_path") == map.fetch("worker_entry").fetch("interpreter").fetch("path"))
|
|
607
|
+
end
|
|
608
|
+
unless bootstrap.fetch("sha256") == map.fetch("bootstrap_sha256") && elf_architecture?(bootstrap.fetch("host_path")) &&
|
|
609
|
+
executables.all? { |artifact| File.executable?(artifact.fetch("host_path")) }
|
|
610
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed bootstrap or executable is unsafe"
|
|
611
|
+
end
|
|
612
|
+
wire.root_path!(File.dirname(service.fetch("socket_path")), directory: true, owner: service.fetch("uid"))
|
|
613
|
+
if authority_state
|
|
614
|
+
fixed = project(map.fetch("project_id"))
|
|
615
|
+
([service.fetch("state_root")] + %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| fixed.fetch(key) }).each do |path|
|
|
616
|
+
wire.root_path!(path, directory: true, owner: service.fetch("uid"))
|
|
617
|
+
unless (File.stat(path).mode & 0o077).zero?
|
|
618
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority state root must remain private"
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
map
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|