ace-assign 0.69.0 → 0.71.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/presets/deliver-release.yml +11 -8
- data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/CHANGELOG.md +27 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
- data/lib/ace/assign/authority/ordinary_client.rb +70 -0
- data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
- data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
- data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +206 -0
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +9 -4
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/router.rb +11 -6
- data/lib/ace/assign/authority/server.rb +11 -180
- data/lib/ace/assign/authority/socket_server.rb +255 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
- data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +39 -10
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "socket_server"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
# Explicit ordinary admission over Assign's existing wire/transfer owner.
|
|
9
|
+
# The Lab composition supplies a trusted installed admission object and
|
|
10
|
+
# closed handlers. No protected Deployment or kernel adapter is replaced.
|
|
11
|
+
class OrdinaryServer < SocketServer
|
|
12
|
+
def initialize(admission:, lifecycle:, services: [])
|
|
13
|
+
@admission = admission
|
|
14
|
+
super(service: admission.service, lifecycle: lifecycle, services: services)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def verify_listener!
|
|
20
|
+
@admission.verify_listener!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def authorize_transfer!(request:, peer:, role:, deadline:)
|
|
24
|
+
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dispatch_lifecycle(request:, peer:, role:, deadline:, transfer: nil)
|
|
28
|
+
@lifecycle.dispatch(request: request, peer: peer, role: role, deadline: deadline, transfer: transfer)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def receive(socket)
|
|
32
|
+
deadline = wire.deadline(30)
|
|
33
|
+
peer = @admission.peer(socket, deadline: deadline)
|
|
34
|
+
ingress = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
35
|
+
frame = wire.read(ingress, deadline: [deadline, wire.deadline(5)].min, limit: 16_384, with_size: true)
|
|
36
|
+
request = frame.fetch(:data)
|
|
37
|
+
unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
|
|
38
|
+
request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
39
|
+
request["operation"].is_a?(String) && request["params"].is_a?(Hash)
|
|
40
|
+
raise ArgumentError, "invalid ordinary authority envelope"
|
|
41
|
+
end
|
|
42
|
+
role = @admission.authorize!(request: request, peer: peer, deadline: deadline)
|
|
43
|
+
binding = @lifecycle.transfer_binding(request)
|
|
44
|
+
bodyless_read!(ingress, deadline) unless binding && binding.fetch(:direction) == :upload
|
|
45
|
+
# Both binary bodies and EOF use ancillary-rejecting reads. Replies
|
|
46
|
+
# use the original duplex socket through the wrapper below.
|
|
47
|
+
dispatch_request(Duplex.new(socket, ingress), request, peer, role, deadline, frame_bytes: frame.fetch(:bytesize))
|
|
48
|
+
rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
|
|
49
|
+
refusal(socket, "invalid_input")
|
|
50
|
+
rescue AttemptErrors::UnauthorizedIdentity, SecurityError
|
|
51
|
+
refusal(socket, "unauthorized")
|
|
52
|
+
rescue AttemptErrors::Conflict
|
|
53
|
+
refusal(socket, "conflict")
|
|
54
|
+
rescue AttemptErrors::NotFound
|
|
55
|
+
refusal(socket, "missing")
|
|
56
|
+
rescue AttemptErrors::BoundedResultUnavailable
|
|
57
|
+
refusal(socket, "bounded_result")
|
|
58
|
+
rescue StandardError
|
|
59
|
+
refusal(socket, "evidence_unavailable")
|
|
60
|
+
ensure
|
|
61
|
+
socket.close unless socket.closed?
|
|
62
|
+
@mutex.synchronize { @connections.delete(socket) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Duplex
|
|
66
|
+
def initialize(socket, ingress) = (@socket, @ingress = socket, ingress)
|
|
67
|
+
def to_io = @socket
|
|
68
|
+
def read_nonblock(*args, **options) = @ingress.read_nonblock(*args, **options)
|
|
69
|
+
def write_nonblock(*args, **options) = @socket.write_nonblock(*args, **options)
|
|
70
|
+
end
|
|
71
|
+
private_constant :Duplex
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
# Replacement transfers only result ownership, never worker execution.
|
|
7
|
+
# The existing definition chain serializes it with canonical generation.
|
|
8
|
+
module OrdinarySupervisor
|
|
9
|
+
SUPERVISOR_OPERATIONS = %w[supervisor_status adopt_supervisor].freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def supervisor_operation(request, peer, role, deadline)
|
|
14
|
+
params = request.fetch("params")
|
|
15
|
+
assignment = params.fetch("assignment_id")
|
|
16
|
+
token!(assignment)
|
|
17
|
+
registered = registration(assignment, commit: @journal.ref_value)
|
|
18
|
+
raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
|
|
19
|
+
if request.fetch("operation") == "supervisor_status"
|
|
20
|
+
strict!(params, %w[assignment_id])
|
|
21
|
+
raise ArgumentError, "supervisor status mutation must be null" unless request.fetch("mutation_id").nil?
|
|
22
|
+
commit = @journal.ref_value
|
|
23
|
+
selected = supervisor_selection(registered, commit)
|
|
24
|
+
authorize!(request, peer, role, deadline)
|
|
25
|
+
return {data: selected.merge("journal_commit" => commit), replayed: false}
|
|
26
|
+
end
|
|
27
|
+
strict!(params, %w[assignment_id expected_generation pane previous_binding_sha256])
|
|
28
|
+
token!(request.fetch("mutation_id"))
|
|
29
|
+
unless role == :supervisor && params.fetch("expected_generation").is_a?(Integer) &&
|
|
30
|
+
params.fetch("expected_generation").positive? && params.fetch("previous_binding_sha256").is_a?(String) &&
|
|
31
|
+
params.fetch("previous_binding_sha256").match?(/\A[0-9a-f]{64}\z/)
|
|
32
|
+
raise AttemptErrors::UnauthorizedIdentity, "supervisor adoption requires exact current selection"
|
|
33
|
+
end
|
|
34
|
+
observed = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
|
|
35
|
+
raise AttemptErrors::UnauthorizedIdentity, "replacement is not an installed coordinator" unless observed.role == "coordinator"
|
|
36
|
+
binding = observed.runtime_binding
|
|
37
|
+
digest = Atoms::EvidenceDigest.digest({"project_id" => @project, "params" => params, "binding" => binding})
|
|
38
|
+
@exclusion.with_shared_multi([@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(assignment)], deadline: deadline) do
|
|
39
|
+
@journal.mutate(assignment_id: assignment, attempt_id: "definition-#{assignment}",
|
|
40
|
+
mutation_id: request.fetch("mutation_id"), operation: "adopt_supervisor", parameters_digest: digest,
|
|
41
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
|
|
42
|
+
replay_authorizer: -> {
|
|
43
|
+
verify_new_supervisor!(request, peer, role, deadline, observed)
|
|
44
|
+
unless supervisor_selection(registered, @journal.ref_value).fetch("binding") == binding
|
|
45
|
+
raise AttemptErrors::UnauthorizedIdentity, "supervisor adoption was superseded"
|
|
46
|
+
end
|
|
47
|
+
}) do |_events, prefix, _generation|
|
|
48
|
+
verify_new_supervisor!(request, peer, role, deadline, observed)
|
|
49
|
+
previous = supervisor_selection(registered, prefix)
|
|
50
|
+
unless previous.fetch("binding_sha256") == params.fetch("previous_binding_sha256") && previous.fetch("binding") != binding
|
|
51
|
+
raise AttemptErrors::Conflict, "original supervisor selection changed"
|
|
52
|
+
end
|
|
53
|
+
ended = @admission.supervisor_ended!(binding: previous.fetch("binding"), deadline: deadline)
|
|
54
|
+
unless ended.is_a?(Hash) && ended["liveness"] == "ended"
|
|
55
|
+
raise AttemptErrors::EvidenceUnavailable, "original supervisor generation has not ended"
|
|
56
|
+
end
|
|
57
|
+
verify_new_supervisor!(request, peer, role, deadline, observed)
|
|
58
|
+
{events: [], data: {"authority" => "ordinary", "assignment_id" => assignment, "project_id" => @project,
|
|
59
|
+
"previous_binding" => previous.fetch("binding"), "binding" => binding,
|
|
60
|
+
"binding_sha256" => Atoms::EvidenceDigest.digest(binding), "ended_observation" => ended}}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def verify_new_supervisor!(request, peer, role, deadline, observed)
|
|
66
|
+
authorize!(request, peer, role, deadline)
|
|
67
|
+
unless @admission.identity!(peer: peer, pane: request.fetch("params").fetch("pane"), deadline: deadline) == observed
|
|
68
|
+
raise AttemptErrors::UnauthorizedIdentity, "replacement supervisor changed during adoption"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def supervisor_selection(registered, commit)
|
|
73
|
+
assignment = registered.fetch("assignment_id")
|
|
74
|
+
events = @journal.read_events(assignment, commit: commit).select { |event| event.fetch("attempt_id") == "definition-#{assignment}" }
|
|
75
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
76
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary supervisor history is corrupt"
|
|
77
|
+
end
|
|
78
|
+
selected = registered.fetch("supervisor_binding")
|
|
79
|
+
generation = 1
|
|
80
|
+
# Validate the entire old→new chain rather than trusting a local cache.
|
|
81
|
+
events.select { |event| event["type"] == "authority_mutation" }.each do |event|
|
|
82
|
+
payload = event.fetch("payload")
|
|
83
|
+
next if payload.fetch("operation") == "register_assignment"
|
|
84
|
+
data = payload.fetch("data")
|
|
85
|
+
unless payload.fetch("operation") == "adopt_supervisor" && data.fetch("previous_binding") == selected &&
|
|
86
|
+
data.values_at("authority", "assignment_id", "project_id", "generation") == ["ordinary", assignment, @project, generation + 1] &&
|
|
87
|
+
data.fetch("binding_sha256") == Atoms::EvidenceDigest.digest(data.fetch("binding")) &&
|
|
88
|
+
data.dig("ended_observation", "liveness") == "ended"
|
|
89
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary supervisor adoption chain differs"
|
|
90
|
+
end
|
|
91
|
+
selected, generation = data.fetch("binding"), data.fetch("generation")
|
|
92
|
+
end
|
|
93
|
+
{"project_id" => @project, "assignment_id" => assignment, "generation" => generation,
|
|
94
|
+
"binding" => selected, "binding_sha256" => Atoms::EvidenceDigest.digest(selected)}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../atoms/candidate_limits"
|
|
4
|
+
|
|
3
5
|
require "json"
|
|
4
6
|
require "digest"
|
|
5
7
|
require "yaml"
|
|
@@ -15,7 +17,7 @@ module Ace
|
|
|
15
17
|
# byte contract, never a registration, attempt or progress ledger.
|
|
16
18
|
class PreparedWork
|
|
17
19
|
MAX_TEXT = 1024 * 1024
|
|
18
|
-
MAX_TOTAL =
|
|
20
|
+
MAX_TOTAL = Atoms::CandidateLimits::PREPARED_BYTES
|
|
19
21
|
MAX_MANIFEST = 32_768
|
|
20
22
|
TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}\z/
|
|
21
23
|
SCOPE = /\A[0-9]+(?:\.[0-9]+)*\z/
|
|
@@ -28,7 +30,10 @@ module Ace
|
|
|
28
30
|
def initialize(files:)
|
|
29
31
|
invalid!("file inventory") unless files.is_a?(Hash) && files.size.between?(4, 4096)
|
|
30
32
|
@files = files.to_h do |path, bytes|
|
|
31
|
-
invalid!("file path") unless path.is_a?(String) && !path.start_with?("/") &&
|
|
33
|
+
invalid!("file path") unless path.is_a?(String) && !path.start_with?("/") &&
|
|
34
|
+
path.bytesize <= Atoms::CandidateLimits::PATH_BYTES &&
|
|
35
|
+
path.split("/", -1).all? { |part| !part.empty? && part.bytesize <= Atoms::CandidateLimits::PATH_COMPONENT_BYTES &&
|
|
36
|
+
!%w[. .. .git].include?(part.downcase) }
|
|
32
37
|
invalid!("text size") unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_TEXT)
|
|
33
38
|
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
34
39
|
invalid!("UTF-8") unless text.valid_encoding? && !text.include?("\0")
|
|
@@ -69,9 +74,9 @@ module Ace
|
|
|
69
74
|
# The existing transport verifies complete Git objects before this
|
|
70
75
|
# shared byte contract parses anything. Preserve the original input for
|
|
71
76
|
# the eventual registration journal; CandidateTransfer may reexport it.
|
|
72
|
-
def self.admit(bytes:, head:, tree:, sha256:, size:, root:)
|
|
77
|
+
def self.admit(bytes:, head:, tree:, sha256:, size:, root:, deadline: nil)
|
|
73
78
|
transfer = CandidateTransfer.new(root: root)
|
|
74
|
-
transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size) do |repository, deadline, result|
|
|
79
|
+
transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size, deadline: deadline) do |repository, deadline, result|
|
|
75
80
|
raise ArgumentError, "prepared_input_invalid: Git tree differs" unless result.fetch("tree") == tree
|
|
76
81
|
new(files: transfer.prepared_files(repository: repository, deadline: deadline, head: head))
|
|
77
82
|
end
|
|
@@ -12,7 +12,7 @@ module Ace
|
|
|
12
12
|
class ProtectedAssignmentContext
|
|
13
13
|
attr_reader :uid
|
|
14
14
|
|
|
15
|
-
def self.load
|
|
15
|
+
def self.load(uid: Process.uid)
|
|
16
16
|
paths = [Deployment::PATH, DeploymentHistory::PATH]
|
|
17
17
|
installed = paths.any? do |path|
|
|
18
18
|
begin
|
|
@@ -22,13 +22,13 @@ module Ace
|
|
|
22
22
|
false
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
-
return new(deployment: nil, history: nil) unless installed
|
|
25
|
+
return new(deployment: nil, history: nil, uid: uid) unless installed
|
|
26
26
|
deployment = Deployment.load
|
|
27
27
|
history = DeploymentHistory.load
|
|
28
28
|
unless history.selects?(deployment)
|
|
29
29
|
raise AttemptErrors::EvidenceUnavailable, "installed protected descriptor is not retained"
|
|
30
30
|
end
|
|
31
|
-
new(deployment: deployment, history: history)
|
|
31
|
+
new(deployment: deployment, history: history, uid: uid)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def initialize(deployment:, history:, uid: Process.uid, kernel: nil, env: ENV,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "rubygems/package"
|
|
8
|
+
require_relative "../molecules/canonical_evidence"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Assign
|
|
12
|
+
module Authority
|
|
13
|
+
# One retained gem and the source-owned publication input it binds.
|
|
14
|
+
# Inspect bounded metadata only; never extract or evaluate build files.
|
|
15
|
+
module ReleaseArtifactManifest
|
|
16
|
+
FIELDS = %w[gem_name version platform registry sha256 bytes].freeze
|
|
17
|
+
NAME = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
18
|
+
MAX_METADATA_BYTES = 1024 * 1024
|
|
19
|
+
REGISTRY = "https://rubygems.org"
|
|
20
|
+
|
|
21
|
+
def self.validate!(value)
|
|
22
|
+
unless value.is_a?(Hash) && value.keys.sort == FIELDS.sort &&
|
|
23
|
+
value["gem_name"].is_a?(String) && NAME.match?(value["gem_name"]) &&
|
|
24
|
+
value["version"].is_a?(String) && value["version"].bytesize.between?(1, 128) && Gem::Version.correct?(value["version"]) &&
|
|
25
|
+
value["platform"].is_a?(String) && NAME.match?(value["platform"]) &&
|
|
26
|
+
value["registry"] == REGISTRY && value["sha256"].is_a?(String) && value["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
27
|
+
value["bytes"].is_a?(Integer) && value["bytes"].between?(1, Molecules::CanonicalEvidence::MAX_RELEASE_ARTIFACT_BYTES)
|
|
28
|
+
raise ArgumentError, "invalid release artifact manifest"
|
|
29
|
+
end
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.verify!(bytes, manifest)
|
|
34
|
+
validate!(manifest)
|
|
35
|
+
unless bytes.is_a?(String) && bytes.bytesize == manifest.fetch("bytes") &&
|
|
36
|
+
Digest::SHA256.hexdigest(bytes.b) == manifest.fetch("sha256")
|
|
37
|
+
raise AttemptErrors::ReceiptRejected, "release artifact bytes differ from the manifest"
|
|
38
|
+
end
|
|
39
|
+
metadata = metadata!(bytes)
|
|
40
|
+
unless metadata == manifest.slice("gem_name", "version", "platform")
|
|
41
|
+
raise AttemptErrors::ReceiptRejected, "release artifact gem metadata differs from the manifest"
|
|
42
|
+
end
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Shared immutable input for retention and the later fixed publisher.
|
|
47
|
+
# Candidate/reference come from the installed canonical owner, not wire
|
|
48
|
+
# claims. Its digest is the existing service authorization input_digest.
|
|
49
|
+
def self.publication_input(manifest:, candidate:, reference:)
|
|
50
|
+
validate!(manifest)
|
|
51
|
+
{"target" => {"resource" => "rubygems:#{manifest.fetch('gem_name')}:#{manifest.fetch('version')}",
|
|
52
|
+
"artifact_digest" => manifest.fetch("sha256")},
|
|
53
|
+
"publication" => manifest.slice("gem_name", "version", "platform", "registry").merge(
|
|
54
|
+
"head" => candidate.fetch("head"), "tree" => candidate.fetch("tree"), "candidate_reference" => reference,
|
|
55
|
+
"artifact_bytes" => manifest.fetch("bytes"), "artifact_relative_path" => "artifacts/#{manifest.fetch('sha256')}.gem")}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.metadata!(bytes)
|
|
59
|
+
metadata = nil
|
|
60
|
+
Gem::Package::TarReader.new(StringIO.new(bytes)) do |archive|
|
|
61
|
+
archive.each do |entry|
|
|
62
|
+
next unless entry.full_name == "metadata.gz"
|
|
63
|
+
unless metadata.nil? && entry.file? && entry.header.size.between?(1, MAX_METADATA_BYTES)
|
|
64
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is missing, repeated or oversized"
|
|
65
|
+
end
|
|
66
|
+
compressed = entry.read
|
|
67
|
+
Zlib::GzipReader.wrap(StringIO.new(compressed)) do |reader|
|
|
68
|
+
metadata = reader.read(MAX_METADATA_BYTES + 1)
|
|
69
|
+
if metadata.bytesize > MAX_METADATA_BYTES
|
|
70
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata exceeds its bound"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is unavailable" unless metadata
|
|
76
|
+
specification = Gem::Specification.from_yaml(metadata)
|
|
77
|
+
unless specification.is_a?(Gem::Specification)
|
|
78
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is invalid"
|
|
79
|
+
end
|
|
80
|
+
{"gem_name" => specification.name, "version" => specification.version.to_s,
|
|
81
|
+
"platform" => specification.platform.to_s}
|
|
82
|
+
rescue AttemptErrors::ReceiptRejected
|
|
83
|
+
raise
|
|
84
|
+
rescue StandardError
|
|
85
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is invalid"
|
|
86
|
+
end
|
|
87
|
+
private_class_method :metadata!
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "private_directory"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Immutable requester inputs, never evidence that an operation executed.
|
|
11
|
+
module RetainedInput
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def read_file(path, limit)
|
|
15
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
16
|
+
before = file.stat
|
|
17
|
+
raise ArgumentError, "retained input is not a bounded regular file" unless before.file? && before.size.between?(0, limit)
|
|
18
|
+
bytes = (file.read(limit + 1) || "").b
|
|
19
|
+
after, selected = file.stat, File.lstat(path)
|
|
20
|
+
identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
|
|
21
|
+
unless bytes.bytesize == before.size && identity.call(before) == identity.call(after) &&
|
|
22
|
+
identity.call(after) == identity.call(selected) && !selected.symlink?
|
|
23
|
+
raise ArgumentError, "retained input changed during read"
|
|
24
|
+
end
|
|
25
|
+
bytes.freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def json(path, limit = 16_384)
|
|
30
|
+
JSON.parse(read_file(path, limit), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_output(path)
|
|
34
|
+
raise ArgumentError, "output must be a new directory path" unless path.is_a?(String) && !path.empty?
|
|
35
|
+
directory = File.expand_path(path)
|
|
36
|
+
Dir.mkdir(directory, 0o700)
|
|
37
|
+
PrivateDirectory.verify!(directory)
|
|
38
|
+
directory
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_file(directory, name, bytes)
|
|
42
|
+
raise ArgumentError, "retained filename is invalid" unless File.basename(name) == name && !%w[. ..].include?(name)
|
|
43
|
+
File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
44
|
+
file.write(bytes)
|
|
45
|
+
file.flush
|
|
46
|
+
file.fsync
|
|
47
|
+
end
|
|
48
|
+
File.open(directory) { |file| file.fsync }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -4,10 +4,10 @@ module Ace
|
|
|
4
4
|
module Authority
|
|
5
5
|
# Source-owned handlers share one listener. Requests never name a method.
|
|
6
6
|
class Router
|
|
7
|
-
def initialize(launch
|
|
7
|
+
def initialize(launch: nil, handlers: [])
|
|
8
8
|
@launch = launch
|
|
9
9
|
@routes = {}
|
|
10
|
-
([launch] + handlers).each do |handler|
|
|
10
|
+
([launch].compact + handlers).each do |handler|
|
|
11
11
|
handler.class::OPERATIONS.each do |operation|
|
|
12
12
|
raise ArgumentError, "duplicate authority operation" if @routes.key?(operation)
|
|
13
13
|
@routes[operation] = handler
|
|
@@ -16,11 +16,12 @@ module Ace
|
|
|
16
16
|
@routes.freeze
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def dispatch(request:, peer:, role:, transfer: nil)
|
|
19
|
+
def dispatch(request:, peer:, role:, transfer: nil, deadline: nil)
|
|
20
20
|
validate_review_namespace!(request)
|
|
21
21
|
handler = @routes.fetch(request.fetch("operation")) { raise ArgumentError, "unknown authority operation" }
|
|
22
22
|
options = {request: request, peer: peer, role: role}
|
|
23
23
|
options[:transfer] = transfer if transfer
|
|
24
|
+
options[:deadline] = deadline if deadline
|
|
24
25
|
handler.dispatch(**options)
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -42,10 +43,12 @@ module Ace
|
|
|
42
43
|
binding
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def authorize_transfer!(request:, peer:, role:)
|
|
46
|
+
def authorize_transfer!(request:, peer:, role:, deadline: nil)
|
|
46
47
|
validate_review_namespace!(request)
|
|
47
48
|
handler = @routes.fetch(request.fetch("operation"))
|
|
48
|
-
|
|
49
|
+
options = {request: request, peer: peer, role: role}
|
|
50
|
+
options[:deadline] = deadline if deadline
|
|
51
|
+
handler.authorize_transfer!(**options)
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def validate_review_namespace!(request)
|
|
@@ -60,14 +63,16 @@ module Ace
|
|
|
60
63
|
private :validate_review_namespace!
|
|
61
64
|
|
|
62
65
|
def close
|
|
63
|
-
@
|
|
66
|
+
@routes.values.uniq.each { |handler| handler.close if handler.respond_to?(:close) }
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def serve_launch_control!(**options)
|
|
70
|
+
raise AttemptErrors::EvidenceUnavailable, "launch control is unavailable in this composition" unless @launch
|
|
67
71
|
@launch.serve_launch_control!(**options)
|
|
68
72
|
end
|
|
69
73
|
|
|
70
74
|
def gate_ready(**options)
|
|
75
|
+
raise AttemptErrors::EvidenceUnavailable, "launch gate is unavailable in this composition" unless @launch
|
|
71
76
|
@launch.gate_ready(**options)
|
|
72
77
|
end
|
|
73
78
|
end
|