ace-assign 0.69.0 → 0.70.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 +7 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- 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 +94 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -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_results.rb +196 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/prepared_work.rb +2 -2
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- 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 +2 -2
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- 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 +141 -34
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +51 -36
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +23 -9
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "transfer_codec"
|
|
6
|
+
require_relative "../molecules/evidence_journal"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Assign
|
|
10
|
+
module Authority
|
|
11
|
+
# Shared endpoint lifetime and bounded wire protocol. Concrete source
|
|
12
|
+
# compositions authenticate their own installed principals before dispatch.
|
|
13
|
+
# Protected deployment/kernel requirements remain in Server.
|
|
14
|
+
class SocketServer
|
|
15
|
+
CONNECTIONS = 16
|
|
16
|
+
|
|
17
|
+
def initialize(service:, lifecycle:, services: [])
|
|
18
|
+
unless services.is_a?(Array) && services.uniq.size == services.size &&
|
|
19
|
+
services.all? { |owned| owned.respond_to?(:run) && owned.respond_to?(:stop) }
|
|
20
|
+
raise ArgumentError, "authority services require distinct source-owned run/stop lifetimes"
|
|
21
|
+
end
|
|
22
|
+
@service, @lifecycle, @services = service, lifecycle, services.dup.freeze
|
|
23
|
+
@service_threads = []
|
|
24
|
+
@activation_gates = []
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@connections = []
|
|
27
|
+
@handlers = []
|
|
28
|
+
@transfers = 0
|
|
29
|
+
@stopped = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serve
|
|
33
|
+
verify_listener!
|
|
34
|
+
directory = File.dirname(@service.fetch("socket_path"))
|
|
35
|
+
wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
|
|
36
|
+
lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
37
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
38
|
+
raise AttemptErrors::Conflict, "assignment authority already owns its listener"
|
|
39
|
+
end
|
|
40
|
+
path = @service.fetch("socket_path")
|
|
41
|
+
if File.exist?(path) || File.symlink?(path)
|
|
42
|
+
raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
|
|
43
|
+
end
|
|
44
|
+
@listener = UNIXServer.new(path)
|
|
45
|
+
@endpoint = wire.socket_identity(path)
|
|
46
|
+
File.chmod(0o660, path)
|
|
47
|
+
start_services!
|
|
48
|
+
verify_services!
|
|
49
|
+
@activation_gates.each { |gate| gate << :activate }
|
|
50
|
+
until @stopped
|
|
51
|
+
verify_services!
|
|
52
|
+
ready = IO.select([@listener], nil, nil, 0.25)
|
|
53
|
+
next unless ready
|
|
54
|
+
socket = @listener.accept_nonblock(exception: false)
|
|
55
|
+
next if socket == :wait_readable
|
|
56
|
+
admitted = @mutex.synchronize do
|
|
57
|
+
if @connections.size < CONNECTIONS
|
|
58
|
+
@connections << socket
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
unless admitted
|
|
63
|
+
refusal(socket, "busy")
|
|
64
|
+
socket.close
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@handlers.reject! { |handler| !handler.alive? }
|
|
69
|
+
@handlers << Thread.new(socket) { |connection| receive(connection) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rescue IOError, Errno::EBADF
|
|
73
|
+
raise unless @stopped
|
|
74
|
+
ensure
|
|
75
|
+
begin
|
|
76
|
+
stop
|
|
77
|
+
stop_services!
|
|
78
|
+
ensure
|
|
79
|
+
# Closing a stream does not cancel an admitted transaction. Hold
|
|
80
|
+
# the owner lock and endpoint until every handler has ended.
|
|
81
|
+
begin
|
|
82
|
+
@mutex.synchronize { @handlers.dup }.each(&:join)
|
|
83
|
+
@lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
|
|
84
|
+
ensure
|
|
85
|
+
begin
|
|
86
|
+
File.unlink(path) if @endpoint && same_endpoint?(path)
|
|
87
|
+
ensure
|
|
88
|
+
lock&.close
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def request_stop
|
|
95
|
+
@stopped = true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def stop
|
|
99
|
+
@stopped = true
|
|
100
|
+
@listener&.close unless @listener&.closed?
|
|
101
|
+
@mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Installed HITL and merge listeners share the assignment owner lock.
|
|
107
|
+
# Every selected boundary must be ready before assignment ingress opens;
|
|
108
|
+
# a stopped boundary closes admission until the complete owner restarts.
|
|
109
|
+
def start_services!
|
|
110
|
+
@services.each do |owned|
|
|
111
|
+
ready = Queue.new
|
|
112
|
+
activation = Queue.new
|
|
113
|
+
@activation_gates << activation
|
|
114
|
+
thread = Thread.new do
|
|
115
|
+
owned.run(on_ready: lambda do
|
|
116
|
+
ready << :ready
|
|
117
|
+
unless activation.pop == :activate
|
|
118
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority startup was cancelled"
|
|
119
|
+
end
|
|
120
|
+
end)
|
|
121
|
+
rescue Exception => error
|
|
122
|
+
ready << error
|
|
123
|
+
ensure
|
|
124
|
+
ready << :stopped
|
|
125
|
+
end
|
|
126
|
+
thread.report_on_exception = false
|
|
127
|
+
@service_threads << [owned, thread]
|
|
128
|
+
result = ready.pop(timeout: 5)
|
|
129
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority service startup did not become ready" unless result == :ready || result.is_a?(Exception)
|
|
130
|
+
raise result if result.is_a?(Exception)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def verify_services!
|
|
135
|
+
unless @service_threads.all? { |_, thread| thread.alive? }
|
|
136
|
+
raise Ace::Runtime::RuntimeUnavailableError, "an owned service stopped before its authority"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stop_services!
|
|
141
|
+
errors = []
|
|
142
|
+
@activation_gates.each { |gate| gate << :cancel }
|
|
143
|
+
@service_threads.reverse_each do |owned, _|
|
|
144
|
+
owned.stop
|
|
145
|
+
rescue Exception => error
|
|
146
|
+
errors << error
|
|
147
|
+
end
|
|
148
|
+
@service_threads.each do |_, thread|
|
|
149
|
+
thread.join
|
|
150
|
+
rescue Exception => error
|
|
151
|
+
errors << error
|
|
152
|
+
end
|
|
153
|
+
raise errors.first unless errors.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def same_endpoint?(path)
|
|
157
|
+
wire.socket_identity(path) == @endpoint
|
|
158
|
+
rescue StandardError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def verify_listener!
|
|
163
|
+
raise NotImplementedError, "source composition must verify its installed listener"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def dispatch_request(socket, request, peer, role, deadline, frame_bytes:)
|
|
167
|
+
params = request.fetch("params")
|
|
168
|
+
admitted_transfer = false
|
|
169
|
+
Molecules::EvidenceJournal.with_event_read_operation do
|
|
170
|
+
binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
|
|
171
|
+
if binding
|
|
172
|
+
raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
|
|
173
|
+
raise ArgumentError, "transfer control header is oversized" if frame_bytes > 16_384
|
|
174
|
+
authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
|
|
175
|
+
admitted_transfer = @mutex.synchronize do
|
|
176
|
+
if @transfers < 2
|
|
177
|
+
@transfers += 1
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
|
|
182
|
+
codec = transfer_codec
|
|
183
|
+
if binding.fetch(:direction) == :upload
|
|
184
|
+
result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
|
|
185
|
+
dispatch_lifecycle(request: request, peer: peer, role: role, transfer: input, deadline: deadline)
|
|
186
|
+
end
|
|
187
|
+
else
|
|
188
|
+
result = dispatch_lifecycle(request: request, peer: peer, role: role, deadline: deadline)
|
|
189
|
+
parts = result.fetch(:transfer_parts)
|
|
190
|
+
descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
|
|
191
|
+
result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
|
|
192
|
+
end
|
|
193
|
+
else
|
|
194
|
+
raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
|
|
195
|
+
result = dispatch_lifecycle(request: request, peer: peer, role: role, deadline: deadline)
|
|
196
|
+
end
|
|
197
|
+
wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
|
|
198
|
+
"transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
|
|
199
|
+
if binding && binding.fetch(:direction) == :download
|
|
200
|
+
codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
ensure
|
|
204
|
+
@mutex.synchronize { @transfers -= 1 } if admitted_transfer
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def authorize_transfer!(request:, peer:, role:, deadline:)
|
|
208
|
+
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def dispatch_lifecycle(request:, peer:, role:, deadline:, transfer: nil)
|
|
212
|
+
options = {request: request, peer: peer, role: role}
|
|
213
|
+
options[:transfer] = transfer if transfer
|
|
214
|
+
@lifecycle.dispatch(**options)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# These closed read operations have no request body. Half-close proves
|
|
218
|
+
# framing completion before status or artifact data can be exposed.
|
|
219
|
+
def bodyless_read!(socket, deadline)
|
|
220
|
+
loop do
|
|
221
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
222
|
+
raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
223
|
+
bytes = socket.read_nonblock(1, exception: false)
|
|
224
|
+
next if bytes == :wait_readable
|
|
225
|
+
raise ArgumentError, "unexpected read request body" unless bytes.nil?
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def transfer_codec
|
|
231
|
+
@mutex.synchronize do
|
|
232
|
+
@transfer_codec ||= begin
|
|
233
|
+
root = @service.fetch("state_root")
|
|
234
|
+
PrivateDirectory.verify!(root)
|
|
235
|
+
spool = File.join(root, "transfers")
|
|
236
|
+
Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
|
|
237
|
+
TransferCodec.new(root: spool)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def refusal(socket, code)
|
|
243
|
+
wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
|
|
244
|
+
deadline: wire.deadline(0.1))
|
|
245
|
+
rescue StandardError
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def wire
|
|
250
|
+
Ace::Runtime::Molecules::ProtectedSocket
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -16,7 +16,7 @@ module Ace
|
|
|
16
16
|
LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
|
|
17
17
|
artifacts: [256 * 1024, 16, 64 * 1024],
|
|
18
18
|
receipt_artifacts: [272 * 1024, 17, 64 * 1024],
|
|
19
|
-
service_input: [64 * 1024, 1, 64 * 1024]}.freeze
|
|
19
|
+
service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [64 * 1024 * 1024, 1, 64 * 1024 * 1024]}.freeze
|
|
20
20
|
SHA256 = /\A[0-9a-f]{64}\z/
|
|
21
21
|
|
|
22
22
|
class Input
|
|
@@ -149,7 +149,7 @@ module Ace
|
|
|
149
149
|
part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
|
|
150
150
|
part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
|
|
151
151
|
descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
|
|
152
|
-
(!%i[candidate service_input prompt_text].include?(purpose) || descriptor["bytes"].positive?)
|
|
152
|
+
(!%i[candidate service_input prompt_text assignment_snapshot].include?(purpose) || descriptor["bytes"].positive?)
|
|
153
153
|
reject!("Invalid or oversized transfer descriptor")
|
|
154
154
|
end
|
|
155
155
|
if purpose == :receipt_artifacts
|
|
@@ -7,12 +7,12 @@ module Ace
|
|
|
7
7
|
module Attempt
|
|
8
8
|
class Evidence < Ace::Support::Cli::Command
|
|
9
9
|
include Attempt::Base
|
|
10
|
-
desc "Read coordinator-accepted live execution or historical
|
|
10
|
+
desc "Read coordinator-accepted live execution or historical execution authority"
|
|
11
11
|
option :attempt, required: true, desc: "Accepted execution attempt ID"
|
|
12
12
|
option :receipt_digest, required: true, desc: "Accepted receipt digest"
|
|
13
13
|
option :kind, default: "check", desc: "Evidence purpose: check, review-collection or review-approval"
|
|
14
14
|
option :check_name, default: "tests", desc: "Required check name (tests binds operation test)"
|
|
15
|
-
option :historical_head, desc: "
|
|
15
|
+
option :historical_head, desc: "Exact recorded head for historical authority only (never current-head evidence)"
|
|
16
16
|
option :format, default: "json", desc: "JSON evidence projection"
|
|
17
17
|
|
|
18
18
|
def call(**options)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Molecules
|
|
6
|
+
# Closed bounded read dialect shared by isolated protected snapshots and
|
|
7
|
+
# trusted installed journals. Neither boundary can dispatch a Git write.
|
|
8
|
+
module CanonicalReadCommands
|
|
9
|
+
BYTE_LIMIT = 256 * 1_048_576
|
|
10
|
+
OID = /\A[0-9a-f]{40}\z/
|
|
11
|
+
|
|
12
|
+
attr_reader :commit
|
|
13
|
+
|
|
14
|
+
# EvidenceJournal uses this only as its existing read command boundary.
|
|
15
|
+
def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
|
|
16
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
17
|
+
unless read_command?(argv, stdin_data)
|
|
18
|
+
refuse!("write_or_unsupported_command")
|
|
19
|
+
end
|
|
20
|
+
result = read_result(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
|
|
21
|
+
[result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def blob_batch(entries, output_limit:)
|
|
25
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
26
|
+
unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
27
|
+
refuse!("unsupported_command")
|
|
28
|
+
end
|
|
29
|
+
result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
|
|
30
|
+
limit: [output_limit, BYTE_LIMIT].min)
|
|
31
|
+
result.stdout.to_s.b
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def history_diff(commits, paths, output_limit:)
|
|
35
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
36
|
+
unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
|
|
37
|
+
commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
|
|
38
|
+
paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
|
|
39
|
+
(paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
|
|
40
|
+
output_limit.is_a?(Integer) && output_limit.positive?
|
|
41
|
+
refuse!("unsupported_command")
|
|
42
|
+
end
|
|
43
|
+
result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
|
|
44
|
+
--no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
|
|
45
|
+
limit: [output_limit, BYTE_LIMIT].min)
|
|
46
|
+
result.stdout.to_s.b
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def blob_sizes(oids)
|
|
50
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
51
|
+
unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
52
|
+
refuse!("unsupported_command")
|
|
53
|
+
end
|
|
54
|
+
run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def blob(path, commit:, max_bytes:)
|
|
58
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
59
|
+
unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
60
|
+
refuse!("unsupported_command")
|
|
61
|
+
end
|
|
62
|
+
result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
|
|
63
|
+
result.stdout.to_s.b
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def read_result(argv, **options)
|
|
69
|
+
run!(argv, **options)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_command?(argv, stdin)
|
|
73
|
+
return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
|
|
74
|
+
allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
|
|
75
|
+
"rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
|
|
76
|
+
"show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
|
|
77
|
+
flags = allowed[argv.first]
|
|
78
|
+
flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require "ace/herdr/molecules/bounded_process"
|
|
7
7
|
require_relative "evidence_journal"
|
|
8
|
+
require_relative "canonical_read_commands"
|
|
8
9
|
require_relative "../authority/private_directory"
|
|
9
10
|
|
|
10
11
|
module Ace
|
|
@@ -13,6 +14,7 @@ module Ace
|
|
|
13
14
|
# Isolates read-only Git from an installed authority's live configuration.
|
|
14
15
|
# The copied view never publishes evidence or mutates the source journal.
|
|
15
16
|
class CanonicalReadSnapshot
|
|
17
|
+
include CanonicalReadCommands
|
|
16
18
|
class Unavailable < AttemptErrors::EvidenceUnavailable; end
|
|
17
19
|
BYTE_LIMIT = 256 * 1_048_576
|
|
18
20
|
ENTRY_LIMIT = 4096
|
|
@@ -94,60 +96,6 @@ module Ace
|
|
|
94
96
|
end
|
|
95
97
|
private :with_snapshot
|
|
96
98
|
|
|
97
|
-
attr_reader :commit
|
|
98
|
-
|
|
99
|
-
# EvidenceJournal uses this only as its existing read command boundary.
|
|
100
|
-
def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
|
|
101
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
102
|
-
unless read_command?(argv, stdin_data)
|
|
103
|
-
refuse!("write_or_unsupported_command")
|
|
104
|
-
end
|
|
105
|
-
result = run!(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
|
|
106
|
-
[result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def blob_batch(entries, output_limit:)
|
|
110
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
111
|
-
unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
112
|
-
refuse!("unsupported_command")
|
|
113
|
-
end
|
|
114
|
-
result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
|
|
115
|
-
limit: [output_limit, BYTE_LIMIT].min)
|
|
116
|
-
result.stdout.to_s.b
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def history_diff(commits, paths, output_limit:)
|
|
120
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
121
|
-
unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
|
|
122
|
-
commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
|
|
123
|
-
paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
|
|
124
|
-
(paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
|
|
125
|
-
output_limit.is_a?(Integer) && output_limit.positive?
|
|
126
|
-
refuse!("unsupported_command")
|
|
127
|
-
end
|
|
128
|
-
result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
|
|
129
|
-
--no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
|
|
130
|
-
limit: [output_limit, BYTE_LIMIT].min)
|
|
131
|
-
result.stdout.to_s.b
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def blob_sizes(oids)
|
|
135
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
136
|
-
unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
137
|
-
refuse!("unsupported_command")
|
|
138
|
-
end
|
|
139
|
-
run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def blob(path, commit:, max_bytes:)
|
|
143
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
144
|
-
unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
145
|
-
refuse!("unsupported_command")
|
|
146
|
-
end
|
|
147
|
-
result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
|
|
148
|
-
result.stdout.to_s.b
|
|
149
|
-
end
|
|
150
|
-
|
|
151
99
|
private
|
|
152
100
|
|
|
153
101
|
def refuse!(reason)
|
|
@@ -352,15 +300,6 @@ module Ace
|
|
|
352
300
|
File.binwrite(ref, "#{@commit}\n")
|
|
353
301
|
end
|
|
354
302
|
|
|
355
|
-
def read_command?(argv, stdin)
|
|
356
|
-
return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
|
|
357
|
-
allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
|
|
358
|
-
"rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
|
|
359
|
-
"show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
|
|
360
|
-
flags = allowed[argv.first]
|
|
361
|
-
flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
|
|
362
|
-
end
|
|
363
|
-
|
|
364
303
|
def run!(argv, stdin_data: "", limit:)
|
|
365
304
|
result = Herdr::Molecules::BoundedProcess.call([@git, "--git-dir=#{@view}", "--no-pager", *argv],
|
|
366
305
|
environment: ENVIRONMENT, chdir: @view, stdin_data: stdin_data,
|