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,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "ace/herdr/molecules/bounded_process"
|
|
9
|
+
require_relative "private_directory"
|
|
10
|
+
|
|
11
|
+
module Ace
|
|
12
|
+
module Assign
|
|
13
|
+
module Authority
|
|
14
|
+
# Untrusted bundles enter a clean bare quarantine. Only complete verified
|
|
15
|
+
# objects are retained; no uploader configuration or worktree is read.
|
|
16
|
+
class CandidateTransfer
|
|
17
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
18
|
+
DEADLINE = 30
|
|
19
|
+
SHA = /\A[0-9a-f]{40}\z/
|
|
20
|
+
|
|
21
|
+
class CampaignReadView
|
|
22
|
+
def initialize(owner:, repository:, base:, deadline:)
|
|
23
|
+
@owner, @repository, @base, @deadline = owner, repository, base, deadline
|
|
24
|
+
@thread, @active = Thread.current, true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close = @active = false
|
|
28
|
+
def head = command("rev-parse", "HEAD").strip
|
|
29
|
+
def base
|
|
30
|
+
commit!(@base)
|
|
31
|
+
@base
|
|
32
|
+
end
|
|
33
|
+
def commit!(revision)
|
|
34
|
+
raise ArgumentError, "campaign revision is invalid" unless revision.is_a?(String) && revision.match?(SHA)
|
|
35
|
+
command("cat-file", "-e", "#{revision}^{commit}")
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
def full_diff?(base, head)
|
|
39
|
+
commit!(base)
|
|
40
|
+
commit!(head)
|
|
41
|
+
!command("diff", "--raw", "--no-ext-diff", "--no-textconv", base, head, "--").empty?
|
|
42
|
+
end
|
|
43
|
+
def clean?
|
|
44
|
+
command("status", "--porcelain", "-z", "--untracked-files=all").split("\0")
|
|
45
|
+
.all? { |entry| entry.start_with?("?? .ace-local/") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def command(*arguments)
|
|
51
|
+
unless @active && Thread.current.equal?(@thread)
|
|
52
|
+
raise AttemptErrors::EvidenceUnavailable, "campaign Git read view escaped its source lifetime"
|
|
53
|
+
end
|
|
54
|
+
@owner.send(:git, @repository, @deadline, *arguments)
|
|
55
|
+
rescue Timeout::Error
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "campaign Git read deadline expired"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def with_campaign_repository(bytes:, sha256:, size:, head:, tree:, base:)
|
|
61
|
+
raise ArgumentError, "campaign repository requires a block" unless block_given?
|
|
62
|
+
deadline = monotonic + DEADLINE
|
|
63
|
+
materialized = materialize_repository(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: @root)
|
|
64
|
+
directory = materialized.fetch("directory")
|
|
65
|
+
reader = CampaignReadView.new(owner: self, repository: directory, base: base, deadline: deadline)
|
|
66
|
+
unless reader.head == head
|
|
67
|
+
raise AttemptErrors::EvidenceUnavailable, "campaign Git head differs from original candidate"
|
|
68
|
+
end
|
|
69
|
+
reader.base
|
|
70
|
+
yield reader
|
|
71
|
+
ensure
|
|
72
|
+
reader&.close
|
|
73
|
+
FileUtils.remove_entry(directory) if directory && File.directory?(directory)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize(root:)
|
|
77
|
+
@root = File.expand_path(root)
|
|
78
|
+
verify_root!
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Export the already validated prepared tree using the same isolated Git
|
|
82
|
+
# boundary as admission. The returned bytes are retained by the producer;
|
|
83
|
+
# registration must not rebuild them after a lost response.
|
|
84
|
+
def export_prepared(work:)
|
|
85
|
+
require_relative "prepared_work"
|
|
86
|
+
reject!("Prepared export requires validated work") unless work.is_a?(PreparedWork)
|
|
87
|
+
verify_root!
|
|
88
|
+
deadline = monotonic + DEADLINE
|
|
89
|
+
Dir.mktmpdir("prepared-export-", @root) do |directory|
|
|
90
|
+
File.chmod(0700, directory)
|
|
91
|
+
repository = File.join(directory, "repository")
|
|
92
|
+
git(directory, deadline, "init", "--template=", "--initial-branch=prepared", repository)
|
|
93
|
+
work.files.each do |path, bytes|
|
|
94
|
+
target = File.join(repository, path)
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(target), mode: 0700)
|
|
96
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
|
|
97
|
+
file.write(bytes)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
git(repository, deadline, "add", "--", ".")
|
|
101
|
+
git(repository, deadline, "-c", "user.name=ACE prepared input", "-c", "user.email=prepared@ace.invalid",
|
|
102
|
+
"-c", "commit.gpgSign=false", "commit", "--no-verify", "-m", "Prepared assignment input")
|
|
103
|
+
head = git(repository, deadline, "rev-parse", "HEAD").strip
|
|
104
|
+
tree = git(repository, deadline, "rev-parse", "HEAD^{tree}").strip
|
|
105
|
+
bundle = File.join(directory, "prepared.bundle")
|
|
106
|
+
git(repository, deadline, "bundle", "create", bundle, "refs/heads/prepared")
|
|
107
|
+
bytes = read_bundle(bundle).freeze
|
|
108
|
+
{"head" => head.freeze, "tree" => tree.freeze, "bundle" => bytes,
|
|
109
|
+
"bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
|
|
110
|
+
"definition_bytes" => work.definition_bytes(head: head, tree: tree)}.freeze
|
|
111
|
+
end
|
|
112
|
+
rescue Timeout::Error
|
|
113
|
+
reject!("Prepared export deadline exceeded")
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
reject!("Prepared export filesystem is unavailable")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def admit(bytes:, sha256:, size:, head:, &consumer)
|
|
119
|
+
unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
|
|
120
|
+
size == bytes.bytesize && sha256.is_a?(String) &&
|
|
121
|
+
Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
|
|
122
|
+
reject!("Candidate bundle binding or size is invalid")
|
|
123
|
+
end
|
|
124
|
+
verify_root!
|
|
125
|
+
deadline = monotonic + DEADLINE
|
|
126
|
+
Dir.mktmpdir("quarantine-", @root) do |directory|
|
|
127
|
+
File.chmod(0700, directory)
|
|
128
|
+
bundle = File.join(directory, "candidate.bundle")
|
|
129
|
+
File.open(bundle, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) { |f| f.write(bytes.b) }
|
|
130
|
+
repository = File.join(directory, "repository")
|
|
131
|
+
git(directory, deadline, "init", "--bare", "--template=", repository)
|
|
132
|
+
# Verification against an empty repository rejects incremental
|
|
133
|
+
# bundles requiring any caller-supplied prerequisites.
|
|
134
|
+
git(repository, deadline, "bundle", "verify", bundle)
|
|
135
|
+
advertised = git(repository, deadline, "bundle", "list-heads", bundle).lines.map { |line| line.split.first }
|
|
136
|
+
reject!("Candidate head is not advertised") unless advertised.include?(head)
|
|
137
|
+
git(repository, deadline, "fetch", "--no-tags", bundle, "#{head}:refs/heads/candidate")
|
|
138
|
+
actual = git(repository, deadline, "rev-parse", "refs/heads/candidate^{commit}").strip
|
|
139
|
+
reject!("Candidate commit differs from declared head") unless actual == head
|
|
140
|
+
git(repository, deadline, "fsck", "--full", "--strict", "--no-reflogs")
|
|
141
|
+
tree = git(repository, deadline, "rev-parse", "#{head}^{tree}").strip
|
|
142
|
+
exported = File.join(directory, "verified.bundle")
|
|
143
|
+
git(repository, deadline, "bundle", "create", exported, "refs/heads/candidate")
|
|
144
|
+
verified = read_bundle(exported)
|
|
145
|
+
# The journal owns candidate identity/generation. This object is
|
|
146
|
+
# only immutable transport bytes, not another authority ledger.
|
|
147
|
+
result = {"head" => head, "tree" => tree, "bundle" => verified,
|
|
148
|
+
"sha256" => Digest::SHA256.hexdigest(verified), "bytes" => verified.bytesize}
|
|
149
|
+
consumer ? consumer.call(repository, deadline, result) : result
|
|
150
|
+
end
|
|
151
|
+
rescue Timeout::Error
|
|
152
|
+
reject!("Candidate transfer deadline exceeded")
|
|
153
|
+
rescue SystemCallError
|
|
154
|
+
reject!("Candidate transfer filesystem is unavailable")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# root is selected from installed peer credentials by the receiver,
|
|
158
|
+
# never a request field. Materialization reads objects rather than Git
|
|
159
|
+
# checkout, so .gitattributes, filters and uploader hooks cannot run.
|
|
160
|
+
def materialize(bytes:, sha256:, size:, head:, tree:, root:)
|
|
161
|
+
self.class.new(root: root)
|
|
162
|
+
admit(bytes: bytes, sha256: sha256, size: size, head: head) do |repository, deadline, result|
|
|
163
|
+
reject!("Candidate tree differs from accepted tree") unless result["tree"] == tree
|
|
164
|
+
destination = Dir.mktmpdir("candidate-", root)
|
|
165
|
+
File.chmod(0700, destination)
|
|
166
|
+
begin
|
|
167
|
+
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
168
|
+
total = 0
|
|
169
|
+
links = []
|
|
170
|
+
entries.each do |entry|
|
|
171
|
+
metadata, path = entry.split("\t", 2)
|
|
172
|
+
mode, kind, oid = metadata.to_s.split(" ")
|
|
173
|
+
parts = path.to_s.split("/", -1)
|
|
174
|
+
unless path && !path.start_with?("/") &&
|
|
175
|
+
parts.none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) } &&
|
|
176
|
+
kind == "blob" && %w[100644 100755 120000].include?(mode) && oid.match?(SHA)
|
|
177
|
+
reject!("Candidate contains unsafe filesystem entry")
|
|
178
|
+
end
|
|
179
|
+
content = git(repository, deadline, "cat-file", "blob", oid, limit: MAX_BYTES).b
|
|
180
|
+
total += content.bytesize
|
|
181
|
+
reject!("Candidate expanded content is oversized") if total > MAX_BYTES
|
|
182
|
+
target = File.join(destination, path)
|
|
183
|
+
FileUtils.mkdir_p(File.dirname(target), mode: 0700)
|
|
184
|
+
if mode == "120000"
|
|
185
|
+
resolved = File.expand_path(content, File.dirname(target))
|
|
186
|
+
unless !content.include?("\0") && !content.start_with?("/") &&
|
|
187
|
+
resolved.start_with?(destination + "/")
|
|
188
|
+
reject!("Candidate symlink escapes private checkout")
|
|
189
|
+
end
|
|
190
|
+
links << [target, content]
|
|
191
|
+
else
|
|
192
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW,
|
|
193
|
+
mode == "100755" ? 0700 : 0600) { |file| file.write(content) }
|
|
194
|
+
end
|
|
195
|
+
yield(path.freeze, mode.freeze, oid.freeze, content.freeze) if block_given?
|
|
196
|
+
end
|
|
197
|
+
links.each { |target, content| File.symlink(content, target) }
|
|
198
|
+
links.each do |target, _content|
|
|
199
|
+
resolved = File.realpath(target)
|
|
200
|
+
reject!("Candidate symlink escapes private checkout") unless resolved.start_with?(destination + "/")
|
|
201
|
+
end
|
|
202
|
+
result.reject { |key, _value| key == "bundle" }.merge("directory" => destination)
|
|
203
|
+
rescue Exception
|
|
204
|
+
# Only this freshly created private disposable directory is
|
|
205
|
+
# removed. An existing peer scratch tree is never cleaned.
|
|
206
|
+
FileUtils.rm_rf(destination)
|
|
207
|
+
raise
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
rescue SystemCallError, ArgumentError
|
|
211
|
+
reject!("Candidate filesystem materialization is unverifiable")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Executing campaign children need local Git history as well as files.
|
|
215
|
+
# Create only our own empty administration; no uploader configuration,
|
|
216
|
+
# checkout filters or hooks enter this private repository.
|
|
217
|
+
def materialize_repository(bytes:, sha256:, size:, head:, tree:, root:)
|
|
218
|
+
deadline = monotonic + DEADLINE
|
|
219
|
+
result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root)
|
|
220
|
+
directory = result.fetch("directory")
|
|
221
|
+
Tempfile.create(["campaign-candidate-", ".bundle"], root) do |file|
|
|
222
|
+
file.binmode
|
|
223
|
+
file.write(bytes); file.flush
|
|
224
|
+
git(directory, deadline, "init", "--template=", "--initial-branch=work")
|
|
225
|
+
git(directory, deadline, "fetch", "--no-tags", file.path, "#{head}:refs/heads/candidate")
|
|
226
|
+
git(directory, deadline, "symbolic-ref", "HEAD", "refs/heads/candidate")
|
|
227
|
+
git(directory, deadline, "read-tree", head)
|
|
228
|
+
reject!("Campaign private repository differs") unless git(directory, deadline, "rev-parse", "HEAD^{tree}").strip == tree
|
|
229
|
+
end
|
|
230
|
+
result
|
|
231
|
+
rescue Exception
|
|
232
|
+
FileUtils.remove_entry(directory) if directory && File.directory?(directory) && !File.symlink?(directory)
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Full immutable review input comes from the same admitted Git objects
|
|
237
|
+
# used for materialization, never a second read of the scratch checkout.
|
|
238
|
+
def review_snapshot(bytes:, sha256:, size:, head:, tree:, root:)
|
|
239
|
+
subject = JSON.generate("schema" => "ace.candidate-review-snapshot/v1", "head" => head, "tree" => tree)
|
|
240
|
+
.delete_suffix("}") + ',"files":['
|
|
241
|
+
first = true
|
|
242
|
+
result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
|
|
243
|
+
entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
|
|
244
|
+
separator = first ? "" : ","
|
|
245
|
+
reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > MAX_BYTES
|
|
246
|
+
subject << separator << entry
|
|
247
|
+
first = false
|
|
248
|
+
end
|
|
249
|
+
subject << "]}"
|
|
250
|
+
result.merge("subject" => subject.freeze)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Prepared instructions have a narrower tree contract than candidates:
|
|
254
|
+
# no executable entries or symlinks, and bounded UTF-8 files. Read only
|
|
255
|
+
# verified Git objects; never run checkout filters or reopen uploader paths.
|
|
256
|
+
def prepared_files(repository:, deadline:, head:)
|
|
257
|
+
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
258
|
+
reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
|
|
259
|
+
total = 0
|
|
260
|
+
files = entries.to_h do |entry|
|
|
261
|
+
metadata, path = entry.split("\t", 2)
|
|
262
|
+
mode, kind, oid = metadata.to_s.split(" ")
|
|
263
|
+
unless path && mode == "100644" && kind == "blob" && oid.match?(SHA) &&
|
|
264
|
+
!path.start_with?("/") && path.split("/", -1).none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) }
|
|
265
|
+
reject!("Prepared tree contains unsafe entry")
|
|
266
|
+
end
|
|
267
|
+
size = git(repository, deadline, "cat-file", "-s", oid).strip
|
|
268
|
+
reject!("Prepared text is oversized") unless size.match?(/\A[0-9]+\z/) && size.to_i.between?(1, 1024 * 1024)
|
|
269
|
+
total += size.to_i
|
|
270
|
+
reject!("Prepared expanded content is oversized") if total > MAX_BYTES
|
|
271
|
+
[path, git(repository, deadline, "cat-file", "blob", oid, limit: 1024 * 1024).b]
|
|
272
|
+
end
|
|
273
|
+
reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
|
|
274
|
+
files
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def review_bytes(bytes)
|
|
280
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
281
|
+
if text.valid_encoding? && !text.include?("\0")
|
|
282
|
+
{"encoding" => "utf-8", "bytes" => text}
|
|
283
|
+
else
|
|
284
|
+
{"encoding" => "base64", "bytes" => [bytes].pack("m0")}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def verify_root!
|
|
289
|
+
PrivateDirectory.verify!(@root)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def git(repository, deadline, *arguments, limit: 65_536)
|
|
293
|
+
remaining = deadline - monotonic
|
|
294
|
+
raise Timeout::Error if remaining <= 0
|
|
295
|
+
# Git variables may redirect object/config storage even in a clean
|
|
296
|
+
# repository. Strip every inherited Git setting and global config.
|
|
297
|
+
environment = ENV.keys.to_h { |key| [key, nil] }
|
|
298
|
+
environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
|
|
299
|
+
"GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
|
|
300
|
+
"GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
|
|
301
|
+
result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
|
|
302
|
+
"-c", "core.hooksPath=/dev/null", "-c", "protocol.file.allow=always", *arguments],
|
|
303
|
+
stdin_data: "", timeout_s: remaining, output_limit: limit)
|
|
304
|
+
reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
|
|
305
|
+
result.stdout
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def read_bundle(path)
|
|
309
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
310
|
+
reject!("Verified bundle is oversized") unless file.stat.file? && file.stat.size <= MAX_BYTES
|
|
311
|
+
bytes = file.read(MAX_BYTES + 1).b
|
|
312
|
+
reject!("Verified bundle is oversized") if bytes.bytesize > MAX_BYTES
|
|
313
|
+
bytes
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def monotonic
|
|
318
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def reject!(message)
|
|
322
|
+
raise AttemptErrors::ReceiptRejected, message
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# Extends the existing held artifact protection solely for the bounded
|
|
10
|
+
# root result view. Private archive storage is never admitted here.
|
|
11
|
+
class CleanupResultProtection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
|
|
12
|
+
class LinuxACL
|
|
13
|
+
def absent!(handle, attribute)
|
|
14
|
+
unless RUBY_PLATFORM.include?("linux")
|
|
15
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL evidence requires Linux"
|
|
16
|
+
end
|
|
17
|
+
function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["fgetxattr"],
|
|
18
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
|
|
19
|
+
size = function.call(handle.fileno, attribute, 0, 0)
|
|
20
|
+
error = Fiddle.last_error
|
|
21
|
+
unless size == -1 && error == Errno::ENODATA::Errno
|
|
22
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL protection is unavailable"
|
|
23
|
+
end
|
|
24
|
+
rescue Fiddle::DLError
|
|
25
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL reader is unavailable"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(result_path:, authority_gid:, acl: LinuxACL.new, **options)
|
|
30
|
+
unless result_path.is_a?(String) && result_path.start_with?("/") && !result_path.include?("\0") &&
|
|
31
|
+
File.expand_path(result_path) == result_path && authority_gid.is_a?(Integer) && authority_gid.positive?
|
|
32
|
+
raise ArgumentError, "invalid fixed cleanup result view"
|
|
33
|
+
end
|
|
34
|
+
super(**options)
|
|
35
|
+
@result_path, @parent, @authority_gid, @acl = result_path.dup.freeze, File.dirname(result_path).freeze, authority_gid, acl
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def verify!(path, handle, directory:)
|
|
39
|
+
super
|
|
40
|
+
return unless path == @result_path || path == @parent
|
|
41
|
+
parent = path == @parent
|
|
42
|
+
stat = handle.stat
|
|
43
|
+
unless directory == parent && stat.gid == @authority_gid &&
|
|
44
|
+
(stat.mode & 0o7777) == (parent ? 0o2750 : 0o640)
|
|
45
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result view mode or group differs"
|
|
46
|
+
end
|
|
47
|
+
@acl.absent!(handle, "system.posix_acl_access")
|
|
48
|
+
@acl.absent!(handle, "system.posix_acl_default") if parent
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require_relative "deployment"
|
|
4
|
+
require_relative "transfer_codec"
|
|
5
|
+
require_relative "campaign_execution"
|
|
6
|
+
require_relative "candidate_transfer"
|
|
7
|
+
require_relative "../molecules/canonical_evidence"
|
|
8
|
+
require_relative "../molecules/execution_scope_lineage"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Assign
|
|
12
|
+
module Authority
|
|
13
|
+
class Client
|
|
14
|
+
Reply = Struct.new(:data, :replayed, :parts, keyword_init: true)
|
|
15
|
+
attr_reader :mapping_id
|
|
16
|
+
def initialize(mapping_id:, deployment: Deployment.load, kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
|
|
17
|
+
@mapping_id, @deployment, @kernel = mapping_id, deployment, kernel
|
|
18
|
+
@map = deployment.mapping(mapping_id)
|
|
19
|
+
@service = deployment.authority(@map.fetch("authority_id"))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(operation, params, mutation_id: nil, timeout: nil, upload_parts: nil, download: false, purpose: nil)
|
|
23
|
+
if operation == "register_assignment" && (!upload_parts || purpose != :candidate || download)
|
|
24
|
+
raise ArgumentError, "prepared registration requires fixed candidate upload"
|
|
25
|
+
end
|
|
26
|
+
if operation == "evidence_fetch" && (!download || purpose != (%w[prepared_work campaign_candidate].include?(params["kind"]) ? :candidate : :artifacts) || upload_parts)
|
|
27
|
+
raise ArgumentError, "evidence fetch requires source-fixed kind download"
|
|
28
|
+
end
|
|
29
|
+
if operation == "submit_result" && (!upload_parts || purpose != :receipt_artifacts || download)
|
|
30
|
+
raise ArgumentError, "result submission requires fixed receipt upload"
|
|
31
|
+
end
|
|
32
|
+
if operation == "campaign_export_result" && (!download || purpose != :artifacts || upload_parts || mutation_id)
|
|
33
|
+
raise ArgumentError, "campaign result requires fixed read-only artifact download"
|
|
34
|
+
end
|
|
35
|
+
if operation == "campaign_record_round" && (!upload_parts || purpose != :receipt_artifacts || download)
|
|
36
|
+
raise ArgumentError, "campaign round requires fixed bounded upload"
|
|
37
|
+
end
|
|
38
|
+
@deployment.verify!(mapping_id, kernel: @kernel)
|
|
39
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
40
|
+
path = @service.fetch("socket_path")
|
|
41
|
+
before = wire.socket_identity(path)
|
|
42
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority endpoint owner differs" unless before.last == @service.fetch("uid")
|
|
43
|
+
raise ArgumentError, "cannot upload and download on one request" if upload_parts && download
|
|
44
|
+
prepared_download = operation == "evidence_fetch" && %w[prepared_work campaign_candidate].include?(params["kind"])
|
|
45
|
+
codec = transfer_codec if upload_parts || download && !prepared_download
|
|
46
|
+
descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
|
|
47
|
+
parameters = upload_parts ? params.merge("transfer" => descriptor) : params
|
|
48
|
+
cap = %w[reserve_attempt close_execution_scope stop_attempt].include?(operation) ? 90 : 30
|
|
49
|
+
duration = timeout || (cap == 90 ? 90 : 5)
|
|
50
|
+
unless duration.is_a?(Numeric) && duration.positive? && duration.finite?
|
|
51
|
+
raise ArgumentError, "authority timeout must be finite and positive"
|
|
52
|
+
end
|
|
53
|
+
deadline = wire.deadline([duration, cap].min)
|
|
54
|
+
wire.connect(path, deadline: deadline) do |socket|
|
|
55
|
+
peer = @kernel.peer(socket)
|
|
56
|
+
unless peer["uid"] == @service.fetch("uid") && peer["gid"] == @service.fetch("gid") &&
|
|
57
|
+
peer["groups"] == @service.fetch("groups") && wire.socket_identity(path) == before
|
|
58
|
+
raise AttemptErrors::UnauthorizedIdentity, "authority endpoint peer changed"
|
|
59
|
+
end
|
|
60
|
+
wire.write(socket, {"version" => 1, "operation" => operation,
|
|
61
|
+
"mutation_id" => mutation_id, "project_id" => @map.fetch("project_id"),
|
|
62
|
+
"params" => parameters.merge("mapping_id" => mapping_id)}, deadline: deadline, limit: upload_parts || download ? 16_384 : wire::LIMIT)
|
|
63
|
+
if %w[bind_inbox finish recover request_review review_status launch_review_intent cancel_review assignment_inventory evidence_fetch 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?(operation) || (operation == "attempt_status" && params.key?("result_candidate_generation"))
|
|
64
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
65
|
+
end
|
|
66
|
+
if upload_parts
|
|
67
|
+
codec.send(socket, parts: upload_parts, descriptor: descriptor, purpose: purpose, deadline: deadline)
|
|
68
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
69
|
+
end
|
|
70
|
+
result = wire.read(socket, deadline: deadline, limit: 16_384)
|
|
71
|
+
unless result.is_a?(Hash) && result["status"] == "ok" && result["data"].is_a?(Hash)
|
|
72
|
+
code = result.is_a?(Hash) ? result.dig("error", "code") : nil
|
|
73
|
+
raise AttemptErrors::BoundedResultUnavailable, "protected authority result exceeds frame bound" if code == "bounded_result"
|
|
74
|
+
raise AttemptErrors::EvidenceUnavailable, "protected authority refused (#{code || 'invalid_response'})"
|
|
75
|
+
end
|
|
76
|
+
transport = result.fetch("transport")
|
|
77
|
+
unless transport.is_a?(Hash) && transport.keys == ["replayed"] && [true, false].include?(transport["replayed"])
|
|
78
|
+
raise AttemptErrors::EvidenceUnavailable, "authority replay metadata is unavailable"
|
|
79
|
+
end
|
|
80
|
+
if operation == "evidence_fetch"
|
|
81
|
+
if %w[prepared_work campaign_candidate].include?(params["kind"]) && transport.fetch("replayed")
|
|
82
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared fetch cannot replay a mutation"
|
|
83
|
+
end
|
|
84
|
+
validate_evidence_download!(result.fetch("data"), params)
|
|
85
|
+
end
|
|
86
|
+
parts = if download
|
|
87
|
+
codec = prepared_transfer_codec(result.fetch("data").fetch("descriptor")) if prepared_download
|
|
88
|
+
codec.receive(socket, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
|
|
89
|
+
Array.new(input.count) { |index| input.bytes(index: index) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
Reply.new(data: result.fetch("data"), replayed: transport.fetch("replayed"), parts: parts)
|
|
93
|
+
end
|
|
94
|
+
rescue SystemCallError, IOError
|
|
95
|
+
raise AttemptErrors::EvidenceUnavailable, "protected authority connection unavailable; no local fallback"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Fixed original-launcher stream. Public calls retain their existing
|
|
100
|
+
# complete-upload EOF contract; no caller-selected framing flag exists.
|
|
101
|
+
def with_launch_control(state:)
|
|
102
|
+
@deployment.verify!(mapping_id, kernel: @kernel)
|
|
103
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
104
|
+
path = @service.fetch("socket_path")
|
|
105
|
+
before = wire.socket_identity(path)
|
|
106
|
+
raise Ace::Runtime::RuntimeUnavailableError, "Authority endpoint owner differs" unless before.last == @service.fetch("uid")
|
|
107
|
+
deadline = wire.deadline(30)
|
|
108
|
+
wire.connect(path, deadline: deadline) do |socket|
|
|
109
|
+
peer = @kernel.peer(socket)
|
|
110
|
+
unless peer.values_at("uid", "gid", "groups") == @service.values_at("uid", "gid", "groups") && wire.socket_identity(path) == before
|
|
111
|
+
raise AttemptErrors::UnauthorizedIdentity, "Authority stream peer changed"
|
|
112
|
+
end
|
|
113
|
+
wire.write(socket, {"version" => 1, "operation" => "launch_control", "mutation_id" => nil,
|
|
114
|
+
"project_id" => @map.fetch("project_id"), "params" => state.slice("assignment_id", "attempt_id").merge("mapping_id" => mapping_id)},
|
|
115
|
+
deadline: deadline, limit: 16_384)
|
|
116
|
+
ready = wire.read(socket, deadline: deadline, limit: 16_384)
|
|
117
|
+
validate_launch_control_ready!(ready, state)
|
|
118
|
+
yield socket, transfer_codec, ready
|
|
119
|
+
end
|
|
120
|
+
rescue IOError, SystemCallError
|
|
121
|
+
raise AttemptErrors::EvidenceUnavailable, "Original launch control connection is unavailable"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def validate_launch_control_ready!(ready, state)
|
|
127
|
+
unless ready.is_a?(Hash) && ready.keys.sort == %w[attempt_id generation journal_commit original_binding_digest type version] &&
|
|
128
|
+
ready["version"].is_a?(Integer) && ready["version"] == 1 && ready["type"] == "launch_control_ready" &&
|
|
129
|
+
ready["attempt_id"] == state.fetch("attempt_id") && ready["generation"].is_a?(Integer) && ready["generation"].positive? &&
|
|
130
|
+
ready["journal_commit"].is_a?(String) && ready["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
131
|
+
ready["original_binding_digest"].is_a?(String) && ready["original_binding_digest"].match?(/\A[0-9a-f]{64}\z/)
|
|
132
|
+
raise AttemptErrors::EvidenceUnavailable, "Original launch control admission is unavailable"
|
|
133
|
+
end
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_evidence_download!(data, params)
|
|
138
|
+
descriptor, transfer = data.values_at("descriptor", "transfer")
|
|
139
|
+
if params["kind"] == "prepared_work"
|
|
140
|
+
validate_prepared_download!(data, params)
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
if params["kind"] == "campaign_candidate"
|
|
144
|
+
validate_campaign_candidate_download!(data, params)
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
|
|
148
|
+
Molecules::CanonicalEvidence.valid_descriptor?(descriptor) &&
|
|
149
|
+
descriptor["project_id"] == @map.fetch("project_id") &&
|
|
150
|
+
{"assignment_id" => "assignment_id", "attempt_id" => "attempt_id", "kind" => "kind",
|
|
151
|
+
"request_id_or_event_id" => "purpose_id", "artifact_id" => "artifact_id"}.all? { |field, key| descriptor[field] == params[key] } &&
|
|
152
|
+
data["generation"].is_a?(Integer) && data["generation"] >= 0 &&
|
|
153
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A[0-9a-f]{40,64}\z/) &&
|
|
154
|
+
transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"] == 1 &&
|
|
155
|
+
transfer["bytes"] == descriptor["bytes"] && transfer["sha256"] == descriptor["sha256"] &&
|
|
156
|
+
transfer["parts"] == [descriptor.slice("bytes", "sha256")]
|
|
157
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical download descriptor differs"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def transfer_codec
|
|
162
|
+
peer = @kernel.capture(Process.pid)
|
|
163
|
+
project = @deployment.project(@map.fetch("project_id"))
|
|
164
|
+
installed = project.fetch("peer_credentials").fetch(peer.fetch("uid").to_s)
|
|
165
|
+
unless peer["gid"] == installed["gid"] && peer["groups"] == installed["groups"]
|
|
166
|
+
raise AttemptErrors::UnauthorizedIdentity, "transfer scratch principal differs"
|
|
167
|
+
end
|
|
168
|
+
TransferCodec.new(root: installed.fetch("scratch_root"))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_prepared_download!(data, params)
|
|
172
|
+
descriptor, transfer = data.values_at("descriptor", "transfer")
|
|
173
|
+
fields = %w[version kind purpose artifact project_id mapping_id assignment_id attempt_id task_id scope definition_digest selection_sha256 prepared_head prepared_tree manifest_bytes manifest_sha256 registration_generation registration_commit original_binding_digest ref bytes sha256 task_context_entry original_worker_identity original_worker_scratch_root workspace_exclusion].sort
|
|
174
|
+
valid = data.keys.sort == %w[descriptor generation journal_commit transfer] && descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
|
|
175
|
+
descriptor["version"].is_a?(Integer) && descriptor["version"] == 1 &&
|
|
176
|
+
params.values_at("kind", "purpose_id", "artifact_id") == %w[prepared_work original_prepared_work prepared_bundle] &&
|
|
177
|
+
descriptor.values_at("kind", "purpose", "artifact") == %w[prepared_work original_prepared_work prepared_bundle] &&
|
|
178
|
+
descriptor["project_id"] == @map.fetch("project_id") && descriptor["mapping_id"] == mapping_id &&
|
|
179
|
+
%w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
|
|
180
|
+
descriptor["task_id"].is_a?(String) && descriptor["task_id"].match?(Deployment::TOKEN) &&
|
|
181
|
+
descriptor["scope"].is_a?(String) && descriptor["scope"].bytesize.between?(1, 128) && descriptor["scope"].match?(/\A[0-9]+(?:\.[0-9]+)*\z/) &&
|
|
182
|
+
%w[definition_digest selection_sha256 manifest_sha256 original_binding_digest sha256].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
183
|
+
%w[prepared_head prepared_tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{40}\z/) } &&
|
|
184
|
+
descriptor["manifest_bytes"].is_a?(Integer) && descriptor["manifest_bytes"].between?(1, 32_768) &&
|
|
185
|
+
descriptor["registration_generation"].is_a?(Integer) && descriptor["registration_generation"].positive? &&
|
|
186
|
+
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, TransferCodec::LIMITS.fetch(:candidate).first) &&
|
|
187
|
+
descriptor["registration_commit"].is_a?(String) && descriptor["registration_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
188
|
+
descriptor["ref"] == "execution/prepared/#{params.fetch('assignment_id')}-#{descriptor['sha256']}.bundle" &&
|
|
189
|
+
data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
190
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
191
|
+
transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"].is_a?(Integer) && transfer["version"] == 1 &&
|
|
192
|
+
transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
|
|
193
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs" unless valid
|
|
194
|
+
TaskContextEntry.validate!(descriptor.fetch("task_context_entry"))
|
|
195
|
+
Molecules::LifecycleExclusion.workspace_reader(projection: descriptor.fetch("workspace_exclusion"))
|
|
196
|
+
unless descriptor.dig("workspace_exclusion", "root_resource", "view_path") ==
|
|
197
|
+
"/run/ace/lifecycle-exclusion/#{descriptor.fetch('mapping_id')}"
|
|
198
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared lifecycle mapping differs"
|
|
199
|
+
end
|
|
200
|
+
identity = descriptor.fetch("original_worker_identity")
|
|
201
|
+
birth = identity.is_a?(Hash) && identity["started_at"]
|
|
202
|
+
boot = birth.is_a?(String) && birth.split(":", -1)[1]
|
|
203
|
+
Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: boot)
|
|
204
|
+
root = descriptor.fetch("original_worker_scratch_root")
|
|
205
|
+
unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
|
|
206
|
+
root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
|
|
207
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared scratch root differs"
|
|
208
|
+
end
|
|
209
|
+
rescue ArgumentError, KeyError
|
|
210
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def validate_campaign_candidate_download!(data, params)
|
|
214
|
+
descriptor, transfer = data.values_at("descriptor", "transfer")
|
|
215
|
+
fields = %w[head tree bytes sha256 candidate_generation campaign_execution mapping_id assignment_id attempt_id
|
|
216
|
+
project_id original_worker_identity original_worker_scratch_root original_binding_digest].sort
|
|
217
|
+
unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
|
|
218
|
+
descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
|
|
219
|
+
params.values_at("purpose_id", "artifact_id") == %w[original_campaign_candidate candidate_bundle] &&
|
|
220
|
+
descriptor["mapping_id"] == mapping_id && descriptor["project_id"] == @map.fetch("project_id") &&
|
|
221
|
+
%w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
|
|
222
|
+
%w[head tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(CandidateTransfer::SHA) } &&
|
|
223
|
+
%w[sha256 original_binding_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
224
|
+
descriptor["candidate_generation"].is_a?(Integer) && descriptor["candidate_generation"].positive? &&
|
|
225
|
+
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, CandidateTransfer::MAX_BYTES) &&
|
|
226
|
+
descriptor["campaign_execution"].is_a?(Hash) && descriptor["campaign_execution"].keys.sort == CampaignExecution::FIELDS &&
|
|
227
|
+
data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
228
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
229
|
+
transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"] == 1 &&
|
|
230
|
+
transfer["version"].is_a?(Integer) &&
|
|
231
|
+
transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
|
|
232
|
+
raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
|
|
233
|
+
end
|
|
234
|
+
identity = descriptor.fetch("original_worker_identity")
|
|
235
|
+
birth = identity.is_a?(Hash) && identity["started_at"]
|
|
236
|
+
Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: birth.to_s.split(":", -1)[1])
|
|
237
|
+
root = descriptor.fetch("original_worker_scratch_root")
|
|
238
|
+
unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
|
|
239
|
+
root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
|
|
240
|
+
raise AttemptErrors::EvidenceUnavailable, "original campaign scratch root differs"
|
|
241
|
+
end
|
|
242
|
+
rescue ArgumentError, KeyError
|
|
243
|
+
raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def prepared_transfer_codec(descriptor)
|
|
247
|
+
peer = @kernel.capture(Process.pid)
|
|
248
|
+
original = descriptor.fetch("original_worker_identity")
|
|
249
|
+
unless peer.values_at("uid", "gid", "groups") == original.values_at("uid", "gid", "groups")
|
|
250
|
+
raise AttemptErrors::UnauthorizedIdentity, "original prepared scratch principal differs"
|
|
251
|
+
end
|
|
252
|
+
TransferCodec.new(root: descriptor.fetch("original_worker_scratch_root"))
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|