ace-lab 0.4.0 → 0.5.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/docs/usage.md +10 -0
  4. data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
  5. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
  6. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
  7. data/lib/ace/lab/atoms/service_input.rb +20 -8
  8. data/lib/ace/lab/authorization_path.rb +20 -0
  9. data/lib/ace/lab/cli/commands/service.rb +45 -0
  10. data/lib/ace/lab/errors.rb +11 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +2 -0
  12. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
  13. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
  14. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
  15. data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
  16. data/lib/ace/lab/molecules/protected_service_handler.rb +9 -40
  17. data/lib/ace/lab/molecules/protected_service_policy.rb +49 -5
  18. data/lib/ace/lab/molecules/service_policy.rb +26 -0
  19. data/lib/ace/lab/organisms/authority_composition.rb +25 -4
  20. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
  21. data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
  22. data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
  23. data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
  24. data/lib/ace/lab/organisms/protected_service_receiver.rb +101 -11
  25. data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
  26. data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
  27. data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
  28. data/lib/ace/lab/version.rb +1 -1
  29. data/lib/ace/lab.rb +2 -22
  30. metadata +34 -4
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_service_listener"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Organisms
8
+ # Worker-side short claim exchange. No local journal/executor fallback;
9
+ # outcome/recovery remains the existing protected authority service API.
10
+ class ProtectedServiceClient
11
+ def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
12
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, wire: Ace::Runtime::Molecules::ProtectedSocket)
13
+ @deployment, @kernel, @wire, @mapping_id = deployment, kernel, wire, mapping_id
14
+ @map = deployment.mapping(mapping_id)
15
+ project = deployment.project(@map.fetch("project_id"))
16
+ @selection = project.fetch("service_receivers").fetch(service_id)
17
+ @credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
18
+ @scratch = project.fetch("peer_credentials").fetch(@map.fetch("worker_uid").to_s).fetch("scratch_root")
19
+ end
20
+
21
+ def preview_workspace_prune(intent:)
22
+ intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
23
+ bytes = JSON.generate(intent)
24
+ deadline = @wire.deadline(5)
25
+ @deployment.verify!(@mapping_id, kernel: @kernel)
26
+ caller = @kernel.capture(Process.pid)
27
+ unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
28
+ raise SecurityError, "preview caller differs"
29
+ end
30
+ codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
31
+ descriptor = codec.descriptor([bytes], purpose: :service_input)
32
+ path = @selection.fetch("socket_path")
33
+ before = @wire.socket_identity(path)
34
+ raise SecurityError, "preview receiver owner differs" unless before.last == @selection.fetch("executor_uid")
35
+ @wire.connect(path, deadline: deadline) do |socket|
36
+ peer = @kernel.peer(socket)
37
+ unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
38
+ @wire.socket_identity(path) == before
39
+ raise SecurityError, "preview receiver peer changed"
40
+ end
41
+ @wire.write(socket, {"version" => 1, "kind" => "preview_workspace_prune", "transfer" => descriptor},
42
+ deadline: deadline, limit: ProtectedServiceListener::LIMIT)
43
+ codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
44
+ socket.shutdown(Socket::SHUT_WR)
45
+ guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
46
+ result = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
47
+ eof!(guarded, deadline)
48
+ raise SecurityError, "preview result unavailable" unless result.is_a?(Hash) && result.key?("maintenance_context")
49
+ Atoms::ProtectedWorkspacePrunePreview.context!(result.fetch("maintenance_context"))
50
+ Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: result.fetch("maintenance_context"))
51
+ end
52
+ end
53
+
54
+ def submit(submission:, input_bytes:, mutation_id:)
55
+ unless submission.is_a?(Hash) && submission.keys.sort == ProtectedServiceReceiver::SUBMISSION.sort &&
56
+ input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
57
+ raise ArgumentError, "receiver submission is invalid"
58
+ end
59
+ submission = JSON.parse(JSON.generate(submission))
60
+ bytes = input_bytes.dup.freeze
61
+ mutation_id = mutation_id.dup.freeze
62
+ deadline = @wire.deadline(5)
63
+ @deployment.verify!(@mapping_id, kernel: @kernel)
64
+ caller = @kernel.capture(Process.pid)
65
+ unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
66
+ raise SecurityError, "receiver caller differs"
67
+ end
68
+ codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
69
+ descriptor = codec.descriptor([bytes], purpose: :service_input)
70
+ path = @selection.fetch("socket_path")
71
+ before = @wire.socket_identity(path)
72
+ raise SecurityError, "receiver endpoint owner differs" unless before.last == @selection.fetch("executor_uid")
73
+ @wire.connect(path, deadline: deadline) do |socket|
74
+ peer = @kernel.peer(socket)
75
+ unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
76
+ @wire.socket_identity(path) == before
77
+ raise SecurityError, "receiver endpoint peer changed"
78
+ end
79
+ @wire.write(socket, {"version" => 1, "submission" => submission,
80
+ "mutation_id" => mutation_id, "transfer" => descriptor}, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
81
+ codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
82
+ socket.shutdown(Socket::SHUT_WR)
83
+ guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
84
+ reply = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
85
+ eof!(guarded, deadline)
86
+ validate_reply!(reply, submission.fetch("request_id"))
87
+ reply
88
+ end
89
+ rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
90
+ {"version" => 1, "type" => "service_claim_unconfirmed", "required_action" => "inspect_canonical_service_status"}
91
+ end
92
+
93
+ private
94
+
95
+ def eof!(socket, deadline)
96
+ loop do
97
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ unless remaining.positive? && IO.select([socket], nil, nil, remaining)
99
+ raise Ace::Runtime::RuntimeUnavailableError, "receiver reply EOF deadline expired"
100
+ end
101
+ byte = socket.read_nonblock(1, exception: false)
102
+ next if byte == :wait_readable
103
+ raise SecurityError, "receiver reply has trailing bytes" unless byte.nil?
104
+ return
105
+ end
106
+ end
107
+
108
+ def validate_reply!(reply, request_id)
109
+ unless reply.is_a?(Hash) && reply["version"].is_a?(Integer) && reply["version"] == 1
110
+ raise SecurityError, "receiver claim reply is invalid"
111
+ end
112
+ if reply["type"] == "service_claim_refused"
113
+ raise SecurityError, "receiver claim refusal is invalid" unless reply.keys.sort == %w[code type version] && %w[busy unavailable].include?(reply["code"])
114
+ return
115
+ end
116
+ data = reply["data"]
117
+ unless reply.keys.sort == %w[data type version] && reply["type"] == "service_claim_accepted" &&
118
+ data.is_a?(Hash) && (data.keys - %w[request_id generation journal_commit state claim replayed]).empty? &&
119
+ data["request_id"] == request_id && data["generation"].is_a?(Integer) && data["generation"].positive? &&
120
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
121
+ %w[uncertain succeeded failed failed-settled].include?(data["state"]) && [true, false].include?(data["replayed"]) &&
122
+ (!data.key?("claim") || %w[created retained].include?(data["claim"]))
123
+ raise SecurityError, "receiver canonical claim identity differs"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "protected_service_worker"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Organisms
9
+ # Fixed receiver ingress. The short connection observes canonical claim
10
+ # acceptance; it does not own or cancel the admitted execution lifetime.
11
+ class ProtectedServiceListener
12
+ LIMIT = 16_384
13
+ REQUEST_KEYS = %w[mutation_id submission transfer version].freeze
14
+
15
+ def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
16
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, receiver: nil,
17
+ wire: Ace::Runtime::Molecules::ProtectedSocket)
18
+ @deployment, @kernel, @wire = deployment, kernel, wire
19
+ @map = deployment.mapping(mapping_id)
20
+ project = deployment.project(@map.fetch("project_id"))
21
+ @selection = project.fetch("service_receivers").fetch(service_id)
22
+ @credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
23
+ @path = @selection.fetch("socket_path")
24
+ @receiver = receiver || ProtectedServiceReceiver.new(
25
+ mapping_id: mapping_id, service_id: service_id, deployment: deployment, kernel: kernel)
26
+ @worker = ProtectedServiceWorker.new(receiver: @receiver)
27
+ @stopping = false
28
+ end
29
+
30
+ def serve
31
+ own_principal!
32
+ parent = File.dirname(@path)
33
+ @wire.root_path!(parent, directory: true, owner: @selection.fetch("executor_uid"))
34
+ # The installed receiver must actually be able to publish a socket
35
+ # accessible to its mapped worker. No ambient ACL/root fixup exists.
36
+ unless @credentials.fetch("gid") == @map.fetch("worker_gid") || @credentials.fetch("groups").include?(@map.fetch("worker_gid"))
37
+ raise SecurityError, "receiver socket group is unavailable"
38
+ end
39
+ @lock = File.open("#{@path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
40
+ stat = @lock.stat
41
+ unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
42
+ raise SecurityError, "receiver listener lock differs"
43
+ end
44
+ unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
45
+ raise Ace::Assign::AttemptErrors::Conflict, "receiver listener already active"
46
+ end
47
+ remove_stale_endpoint!
48
+ @listener = UNIXServer.new(@path)
49
+ File.chown(nil, @map.fetch("worker_gid"), @path)
50
+ File.chmod(0o660, @path)
51
+ @identity = @wire.socket_identity(@path)
52
+ until @stopping
53
+ begin
54
+ socket = @listener.accept
55
+ receive(socket)
56
+ ensure
57
+ socket&.close
58
+ socket = nil
59
+ end
60
+ end
61
+ rescue IOError, Errno::EBADF
62
+ raise unless @stopping
63
+ ensure
64
+ # Never release listener exclusion while an admitted worker exists.
65
+ # Actual operation transport supplies its separate bounded lifetime.
66
+ @worker.close(timeout: 1) until @worker.close(timeout: 0)
67
+ @listener&.close
68
+ remove_owned_endpoint!
69
+ @lock&.close
70
+ end
71
+
72
+ def stop
73
+ @stopping = true
74
+ @worker.close(timeout: 0)
75
+ @listener&.close
76
+ end
77
+
78
+ private
79
+
80
+ def receive(socket)
81
+ deadline = @wire.deadline(5)
82
+ peer = @kernel.peer(socket)
83
+ unless peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
84
+ raise SecurityError, "receiver worker peer differs"
85
+ end
86
+ guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
87
+ request = @wire.read(guarded, deadline: deadline, limit: LIMIT)
88
+ if request.is_a?(Hash) && request["kind"] == "preview_workspace_prune"
89
+ unless request.keys.sort == %w[kind transfer version] && request["version"].is_a?(Integer) && request["version"] == 1
90
+ raise ArgumentError, "receiver preview header differs"
91
+ end
92
+ codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
93
+ bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) do |input|
94
+ raise ArgumentError, "preview requires one intent part" unless input.count == 1
95
+ input.bytes
96
+ end
97
+ intent = JSON.parse(bytes, create_additions: false, max_nesting: 16, allow_nan: false,
98
+ allow_comments: false, allow_duplicate_key: false)
99
+ intent = Atoms::ProtectedWorkspacePrunePreview.intent!(intent)
100
+ result = @receiver.preview_workspace_prune(intent: intent, peer: peer, deadline: deadline)
101
+ @wire.write(socket, result, deadline: deadline, limit: LIMIT)
102
+ return
103
+ end
104
+ strict_request!(request)
105
+ codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
106
+ bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) { |input| input.bytes }
107
+ completed = Queue.new
108
+ @worker.start(submission: request.fetch("submission"), peer: peer,
109
+ input_bytes: bytes, mutation_id: request.fetch("mutation_id"), deadline: deadline,
110
+ on_claim: lambda do |identity|
111
+ begin
112
+ @wire.write(socket, {"version" => 1, "type" => "service_claim_accepted", "data" => identity},
113
+ deadline: deadline, limit: LIMIT)
114
+ rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
115
+ # Only bounded transport failure is swallowed. Original work
116
+ # continues and its accepted identity is observed via status.
117
+ nil
118
+ ensure
119
+ completed << true
120
+ end
121
+ end)
122
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
+ completed.pop(timeout: remaining) if remaining.positive?
124
+ rescue Ace::Assign::AttemptErrors::Conflict
125
+ refuse(socket, "busy", deadline)
126
+ rescue SecurityError, ArgumentError, KeyError, TypeError, Ace::Assign::Error,
127
+ Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
128
+ refuse(socket, "unavailable", deadline)
129
+ end
130
+
131
+ def strict_request!(request)
132
+ unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS &&
133
+ request["version"].is_a?(Integer) && request["version"] == 1 &&
134
+ request["transfer"].is_a?(Hash) && request["transfer"]["version"].is_a?(Integer) && request["transfer"]["version"] == 1 &&
135
+ request["mutation_id"].is_a?(String) && request["mutation_id"].match?(Ace::Assign::Molecules::JournalMutation::ID) &&
136
+ request["submission"].is_a?(Hash) && request["submission"].keys.sort == ProtectedServiceReceiver::SUBMISSION.sort
137
+ raise ArgumentError, "receiver request is invalid"
138
+ end
139
+ walk = [[request, 0]]
140
+ until walk.empty?
141
+ value, depth = walk.pop
142
+ raise ArgumentError, "receiver request depth exceeds bound" if depth > 16
143
+ children = value.is_a?(Hash) ? value.values : (value.is_a?(Array) ? value : [])
144
+ children.each { |child| walk << [child, depth + 1] }
145
+ end
146
+ end
147
+
148
+ def refuse(socket, code, deadline)
149
+ @wire.write(socket, {"version" => 1, "type" => "service_claim_refused", "code" => code}, deadline: deadline, limit: LIMIT)
150
+ rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
151
+ nil
152
+ end
153
+
154
+ def own_principal!
155
+ me = @kernel.capture(Process.pid)
156
+ unless me.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")]
157
+ raise SecurityError, "receiver listener principal differs"
158
+ end
159
+ @kernel.live!(me)
160
+ end
161
+
162
+ def remove_stale_endpoint!
163
+ stat = File.lstat(@path)
164
+ unless stat.socket? && stat.uid == Process.uid
165
+ raise SecurityError, "receiver endpoint differs"
166
+ end
167
+ File.unlink(@path)
168
+ rescue Errno::ENOENT
169
+ nil
170
+ end
171
+
172
+ def remove_owned_endpoint!
173
+ File.unlink(@path) if @identity && @wire.socket_identity(@path) == @identity
174
+ rescue Errno::ENOENT
175
+ nil
176
+ end
177
+
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+ require "rubygems/package"
4
+ require "ace/hitl/providers/lab"
5
+ require_relative "../molecules/protected_publication_publisher"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Organisms
10
+ class ProtectedServiceReceiver
11
+ # One invocation is retained by the existing receiver worker, not a
12
+ # second effect queue. Only canonical continuation can authorize a push.
13
+ def begin_publication!(admitted:, materialized:, deadline:)
14
+ raise SecurityError, "publication receiver already owns an invocation" if @publication
15
+ @publication_hitl ||= Ace::Hitl::Providers::Lab.boundary_client(
16
+ socket_path: Ace::Hitl::Providers::Lab::DEFAULT_SOCKET_PATH,
17
+ policy: Ace::Hitl::Providers::Lab.grants_policy(grants_path: Ace::Hitl::Providers::Lab::DEFAULT_GRANTS_PATH))
18
+ params = admitted.fetch(:params)
19
+ started = admitted.fetch(:started)
20
+ unless started.fetch("executor_process_binding") == @kernel.capture(Process.pid)
21
+ raise SecurityError, "publication original receiver differs"
22
+ end
23
+ @publication = admitted.merge(materialized: materialized, handles: [], challenge: nil,
24
+ ask_id: nil, disposition: "dispatch_started")
25
+ hold_publication_artifact!(@publication)
26
+ verified = publication_registry!(@publication, deadline)
27
+ return complete_publication!(@publication, verified, deadline) if %w[succeeded failed].include?(verified.fetch("classification"))
28
+ unless verified.fetch("classification") == "absent"
29
+ @publication[:disposition] = "uncertain"
30
+ return publication_status("uncertain", verified.fetch("code"))
31
+ end
32
+ publication_push!(@publication, nil, deadline)
33
+ rescue StandardError, SecurityError
34
+ publication_status("uncertain", "publication_admission_unavailable")
35
+ end
36
+
37
+ def tick_publication!(request_id:, deadline:)
38
+ entry = @publication
39
+ return {"request_id" => request_id, "state" => "publication-blocked", "code" => "original-executor-unavailable"} unless entry && entry.dig(:params, "request_id") == request_id
40
+ check_publication!(entry)
41
+ status = publication_call!(entry, "service_status", {}, deadline).data
42
+ if %w[succeeded failed].include?(status["state"])
43
+ close_publication!(entry)
44
+ return projection(status)
45
+ end
46
+ if entry[:disposition] == "uncertain"
47
+ verified = publication_registry!(entry, deadline)
48
+ return complete_publication!(entry, verified, deadline) if verified["classification"] == "succeeded"
49
+ return publication_status("uncertain", "publication_outcome_unconfirmed")
50
+ end
51
+ unless status["dispatch_phase"] == "otp_pending" && status["publication_challenge_digest"] == entry.fetch(:challenge).fetch("publication_challenge_digest")
52
+ return publication_status("uncertain", "publication_challenge_changed")
53
+ end
54
+ publication_ask!(entry, deadline) unless entry[:ask_created]
55
+ ask = @publication_hitl.read(entry.fetch(:ask_id), deadline: deadline)
56
+ check_publication_ask!(entry, ask)
57
+ if %w[cancelled consumed].include?(ask["state"])
58
+ entry[:ask_id] = nil
59
+ entry[:ask_created] = false
60
+ entry[:ask_otp] = nil
61
+ return publication_status("publication-pending", "otp-answer-unavailable")
62
+ end
63
+ if ask.fetch("otp").fetch("expires_at") <= Time.now.to_i
64
+ @publication_hitl.cancel(entry.fetch(:ask_id), reason: "otp-answer-expired", deadline: deadline)
65
+ return publication_status("publication-pending", "otp-answer-expired")
66
+ end
67
+ return publication_status("publication-pending", "otp-answer-pending") unless ask["state"] == "answer-delivered"
68
+ return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1.5
69
+ answer = @publication_hitl.consume(entry.fetch(:ask_id), timeout: 1, operation: "publish", deadline: deadline)
70
+ otp = answer["answer"]
71
+ unless otp.is_a?(String) && otp.match?(/\A[0-9]{6}\z/) && ask.fetch("otp").fetch("expires_at") > Time.now.to_i
72
+ return publication_status("publication-pending", "otp-answer-unavailable")
73
+ end
74
+ check_publication!(entry)
75
+ return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1
76
+ status = publication_call!(entry, "service_status", {}, deadline).data
77
+ return publication_status("publication-pending", "otp-answer-expired") unless ask.fetch("otp").fetch("expires_at") > Time.now.to_i
78
+ entry[:disposition] = "uncertain" # A lost issuing reply is never retried.
79
+ issued = publication_call!(entry, "publication_continue", {
80
+ "expected_generation" => status.fetch("generation"), "input_digest" => entry.fetch(:params).fetch("input_digest"),
81
+ "challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest")}, deadline,
82
+ mutation_id: Digest::SHA256.hexdigest("publication-continue:#{entry.fetch(:challenge).fetch('publication_challenge_digest')}")).data
83
+ return publication_status("uncertain", "publication_issuing_unconfirmed") unless issued["invocation"] == "permitted" && issued["dispatch_phase"] == "issuing"
84
+ entry[:started] = issued
85
+ publication_push!(entry, otp, deadline)
86
+ rescue StandardError, SecurityError
87
+ publication_status("uncertain", "publication_tick_unavailable")
88
+ ensure
89
+ otp&.clear if otp.is_a?(String) && !otp.frozen?
90
+ answer&.delete("answer")
91
+ end
92
+
93
+ def publication_active?
94
+ !@publication.nil?
95
+ end
96
+
97
+ # Called only after the existing worker has stopped its bounded tick.
98
+ # Durable uncertain state and candidate bytes remain for read-only recovery.
99
+ def release_publication_lifetime!
100
+ @publication&.fetch(:handles)&.each { |_path, handle, _identity| handle.close unless handle.closed? }
101
+ @publication = nil
102
+ end
103
+
104
+ private
105
+
106
+ def hold_publication_artifact!(entry)
107
+ root = entry.fetch(:materialized).fetch("directory")
108
+ publication = entry.fetch(:envelope).fetch("input").fetch("publication")
109
+ components = publication.fetch("artifact_relative_path").split("/")
110
+ paths = [root]
111
+ components.each { |component| paths << File.join(paths.last, component) }
112
+ paths.each_with_index do |path, index|
113
+ handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
114
+ stat = handle.stat
115
+ valid = index == paths.size - 1 ? stat.file? && stat.size.between?(1, 64 * 1024 * 1024) : stat.directory?
116
+ unless valid && stat.uid == Process.uid && (stat.mode & 0o022).zero?
117
+ handle.close
118
+ raise SecurityError, "publication staging protection differs"
119
+ end
120
+ entry.fetch(:handles) << [path, handle, publication_identity(stat)]
121
+ end
122
+ entry[:artifact] = paths.last
123
+ spec = Gem::Package.new(paths.last).spec
124
+ unless spec.name == publication.fetch("gem_name") && spec.version.to_s == publication.fetch("version")
125
+ raise SecurityError, "publication artifact metadata differs"
126
+ end
127
+ entry[:platform] = spec.platform.to_s
128
+ check_publication!(entry)
129
+ end
130
+
131
+ def check_publication!(entry)
132
+ @kernel.live!(entry.fetch(:started).fetch("executor_process_binding"))
133
+ raise SecurityError, "publication receiver birth differs" unless @kernel.capture(Process.pid) == entry.fetch(:started).fetch("executor_process_binding")
134
+ entry.fetch(:handles).each do |path, handle, expected|
135
+ unless publication_identity(handle.stat) == expected && publication_identity(File.lstat(path)) == expected
136
+ raise SecurityError, "publication held staging changed"
137
+ end
138
+ end
139
+ artifact = entry.fetch(:handles).last.fetch(1)
140
+ artifact.rewind
141
+ digest = Digest::SHA256.new
142
+ while (chunk = artifact.read(65_536))
143
+ digest.update(chunk)
144
+ end
145
+ unless digest.hexdigest == entry.fetch(:envelope).fetch("input").fetch("target").fetch("artifact_digest")
146
+ raise SecurityError, "publication artifact bytes differ"
147
+ end
148
+ end
149
+
150
+ def publication_identity(stat)
151
+ stat.directory? ? [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode] :
152
+ [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime]
153
+ end
154
+
155
+ def publication_registry!(entry, deadline)
156
+ check_publication!(entry)
157
+ @publication_publisher.verify!(publication: entry.fetch(:envelope).fetch("input").fetch("publication"),
158
+ artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), platform: entry.fetch(:platform), deadline: deadline)
159
+ end
160
+
161
+ def publication_push!(entry, otp, deadline)
162
+ check_publication!(entry)
163
+ entry[:disposition] = "uncertain"
164
+ result = @publication_publisher.push!(operation: entry.fetch(:operation),
165
+ publication: entry.fetch(:envelope).fetch("input").fetch("publication"), artifact: entry.fetch(:artifact),
166
+ artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), otp: otp, deadline: deadline)
167
+ check_publication!(entry)
168
+ verified = publication_registry!(entry, deadline)
169
+ return complete_publication!(entry, verified, deadline) if %w[succeeded failed].include?(verified["classification"])
170
+ if %w[otp-required otp-rejected otp-expired].include?(result["classification"]) && verified["classification"] == "absent"
171
+ return retain_publication_challenge!(entry, result, deadline)
172
+ end
173
+ return complete_publication!(entry, result, deadline) if result["classification"] == "failed" && verified["classification"] == "absent"
174
+ publication_status("uncertain", "publication_outcome_unconfirmed")
175
+ end
176
+
177
+ def retain_publication_challenge!(entry, result, deadline)
178
+ input = entry.fetch(:envelope).fetch("input")
179
+ params = entry.fetch(:params)
180
+ previous = entry[:challenge]&.fetch("publication_challenge_digest")
181
+ need = params.slice("request_id", "input_digest", "candidate_generation", "head").merge(
182
+ "schema" => "ace.publication-otp-need/v1", "classification" => result.fetch("classification"),
183
+ "claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
184
+ "registry" => input.fetch("publication").fetch("registry"), "gem_name" => input.fetch("publication").fetch("gem_name"),
185
+ "version" => input.fetch("publication").fetch("version"), "artifact_digest" => input.fetch("target").fetch("artifact_digest"),
186
+ "dispatch_event_digest" => entry.fetch(:dispatch_event_digest), "previous_challenge_digest" => previous)
187
+ bytes = JSON.generate(need)
188
+ status = publication_call!(entry, "service_status", {}, deadline).data
189
+ entry[:challenge] = publication_call!(entry, "publication_challenge", {
190
+ "input_digest" => params.fetch("input_digest"), "expected_generation" => status.fetch("generation"),
191
+ "receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
192
+ mutation_id: Digest::SHA256.hexdigest("publication-challenge:#{Digest::SHA256.hexdigest(bytes)}"),
193
+ upload_parts: [bytes], purpose: :receipt_artifacts).data
194
+ entry[:disposition] = "otp_pending"
195
+ entry[:ask_id] = nil
196
+ entry[:ask_created] = false
197
+ entry[:ask_otp] = nil
198
+ publication_ask!(entry, deadline)
199
+ publication_status("publication-pending", result.fetch("classification"))
200
+ end
201
+
202
+ def publication_ask!(entry, deadline)
203
+ entry[:ask_id] ||= "publication-#{SecureRandom.hex(12)}"
204
+ binding = entry.fetch(:params).slice("assignment_id", "attempt_id", "request_id", "input_digest", "candidate_generation", "head").merge(
205
+ "schema" => "ace.hitl-publication-binding/v1", "mapping_id" => @mapping_id,
206
+ "claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
207
+ "challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest"))
208
+ entry[:ask_binding] = immutable(binding)
209
+ entry[:ask_otp] ||= immutable({"operation" => "publish", "input_digest" => binding.fetch("input_digest"),
210
+ "result_ref" => entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref"), "expires_at" => Time.now.to_i + 900})
211
+ @publication_hitl.create(id: entry.fetch(:ask_id), assignment: binding.fetch("assignment_id"), attempt: binding.fetch("attempt_id"),
212
+ project: @map.fetch("project_id"), harness: "lab-admin", plan: "Scoped RubyGems publication", kind: "otp",
213
+ question: "Provide the RubyGems OTP for this authorized artifact publication.", ace_hitl_id: entry.fetch(:ask_id),
214
+ publication_binding: binding, otp: entry.fetch(:ask_otp), deadline: deadline)
215
+ entry[:ask_created] = true
216
+ end
217
+
218
+ def check_publication_ask!(entry, ask)
219
+ unless ask["id"] == entry.fetch(:ask_id) && ask["publication_binding"] == entry.fetch(:ask_binding) &&
220
+ ask["publication_requester_peer"] == entry.fetch(:started).fetch("executor_process_binding") &&
221
+ ask.dig("otp", "input_digest") == entry.fetch(:params).fetch("input_digest") &&
222
+ ask.dig("otp", "result_ref") == entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref")
223
+ raise SecurityError, "publication HITL retained request differs"
224
+ end
225
+ end
226
+
227
+ def publication_call!(entry, operation, extra, deadline, **options)
228
+ params = entry.fetch(:params).slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
229
+ params["claim_binding"] = entry.fetch(:claim).fetch("claim_binding") unless operation == "service_status"
230
+ @client.call(operation, params.merge(extra), timeout: publication_remaining!(deadline), **options)
231
+ end
232
+
233
+ def complete_publication!(entry, result, deadline)
234
+ check_publication!(entry)
235
+ input = entry.fetch(:envelope).fetch("input")
236
+ evidence = JSON.generate(input.merge("schema" => "ace.publication-result/v1", "classification" => result.fetch("classification"),
237
+ "code" => result.fetch("code"), "request_id" => entry.fetch(:params).fetch("request_id"),
238
+ "input_digest" => entry.fetch(:params).fetch("input_digest"), "claim_binding" => entry.fetch(:claim).fetch("claim_binding")))
239
+ receipt = entry.fetch(:envelope).fetch("request").slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
240
+ "outcome" => result.fetch("classification") == "succeeded" ? "succeeded" : "failed",
241
+ "evidence" => [{"ref" => "publication-result", "sha256" => Digest::SHA256.hexdigest(evidence)}])
242
+ bytes = JSON.generate(receipt)
243
+ completed = publication_call!(entry, "complete_service", {"receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
244
+ mutation_id: Digest::SHA256.hexdigest("publication-complete:#{entry.fetch(:mutation_id)}"), upload_parts: [bytes, evidence], purpose: :receipt_artifacts).data
245
+ return publication_status("uncertain", "publication_completion_unconfirmed") unless %w[succeeded failed].include?(completed["state"])
246
+ close_publication!(entry)
247
+ projection(completed)
248
+ end
249
+
250
+ def close_publication!(entry)
251
+ entry.fetch(:handles).each { |_path, handle, _identity| handle.close }
252
+ cleanup_staging(entry.fetch(:materialized).fetch("directory"), entry.fetch(:staging_identity))
253
+ @publication = nil
254
+ end
255
+
256
+ def publication_remaining!(deadline)
257
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
258
+ raise IOError, "publication tick deadline expired" unless remaining.positive?
259
+ remaining
260
+ end
261
+
262
+ def publication_status(state, code)
263
+ {"request_id" => @publication&.dig(:params, "request_id"), "state" => state, "code" => code}.freeze
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end