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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +27 -0
  6. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  7. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  8. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  9. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  10. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  11. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  12. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  13. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
  15. data/lib/ace/assign/authority/command_execution.rb +180 -0
  16. data/lib/ace/assign/authority/endcap.rb +4 -2
  17. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  18. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  19. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  20. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  21. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  22. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  23. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  24. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  25. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  26. data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
  27. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  28. data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
  29. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  30. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  31. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  32. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  33. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  34. data/lib/ace/assign/authority/ordinary_results.rb +206 -0
  35. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  36. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  37. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  38. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  39. data/lib/ace/assign/authority/prepared_work.rb +9 -4
  40. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  41. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  42. data/lib/ace/assign/authority/retained_input.rb +53 -0
  43. data/lib/ace/assign/authority/router.rb +11 -6
  44. data/lib/ace/assign/authority/server.rb +11 -180
  45. data/lib/ace/assign/authority/socket_server.rb +255 -0
  46. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  47. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  48. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  49. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  50. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  51. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  52. data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
  53. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  54. data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
  55. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  56. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  57. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  58. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  59. data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
  60. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  61. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  62. data/lib/ace/assign/version.rb +1 -1
  63. 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 = 64 * 1024 * 1024
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?("/") && path.split("/", -1).all? { |part| !part.empty? && !%w[. .. .git].include?(part.downcase) }
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:, handlers: [])
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
- handler.authorize_transfer!(request: request, peer: peer, role: role)
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
- @launch.close
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