ace-lab 0.6.0 → 0.8.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.
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/assign/molecules/installed_journal_read"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # Observe a mutable ordinary worker checkout. The root-installed project
9
+ # selection supplies both boundaries; caller input never selects a Git
10
+ # repository. This is historical observation, not a checkout freeze.
11
+ class OrdinaryWorktreeRead
12
+ def initialize(root:, common_dir:, deadline:)
13
+ @root, @common, @deadline = root, common_dir, deadline
14
+ [root, common_dir].each { |path| absolute!(path) }
15
+ end
16
+
17
+ def observe(cwd)
18
+ absolute!(cwd)
19
+ remaining
20
+ unless within?(cwd, @root)
21
+ unavailable!("worker working directory is outside installed worktrees")
22
+ end
23
+ before = selection(cwd)
24
+ head = git(before, "rev-parse", "--verify", "HEAD^{commit}").strip
25
+ unavailable!("worker HEAD is unavailable") unless head.match?(/\A[0-9a-f]{40}\z/)
26
+ after = selection(cwd)
27
+ unless before == after && git(after, "rev-parse", "--verify", "HEAD^{commit}").strip == head
28
+ unavailable!("worker checkout changed during observation")
29
+ end
30
+ {"cwd" => cwd, "worktree" => before.fetch("worktree"), "git_dir" => before.fetch("git_dir"),
31
+ "common_dir" => @common, "head" => head}
32
+ rescue SystemCallError, IOError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError
33
+ unavailable!("worker checkout cannot be observed")
34
+ end
35
+
36
+ def ref_head(cwd, ref)
37
+ unless ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_./-]+\z}) && !ref.include?("..")
38
+ unavailable!("selected Git ref is invalid")
39
+ end
40
+ with_selected_read(cwd) do |selected|
41
+ head = git(selected, "rev-parse", "--verify", "#{ref}^{commit}").strip
42
+ unavailable!("selected ref is not a commit") unless head.match?(/\A[0-9a-f]{40}\z/)
43
+ head
44
+ end
45
+ end
46
+
47
+ def commit?(cwd, head)
48
+ exact_head!(head)
49
+ with_selected_read(cwd) do |selected|
50
+ git(selected, "rev-parse", "--verify", "--quiet", "#{head}^{commit}", allowed_status: [0, 1], result: true).status.success?
51
+ end
52
+ end
53
+
54
+ def ancestor?(cwd, ancestor, descendant)
55
+ exact_head!(ancestor); exact_head!(descendant)
56
+ with_selected_read(cwd) do |selected|
57
+ git(selected, "merge-base", "--is-ancestor", ancestor, descendant, allowed_status: [0, 1], result: true).status.success?
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def exact_head!(head)
64
+ unavailable!("selected Git head is invalid") unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
65
+ end
66
+
67
+ def with_selected_read(cwd)
68
+ absolute!(cwd)
69
+ unavailable!("selected repository leaves installed worktrees") unless within?(cwd, @root)
70
+ before = selection(cwd)
71
+ value = yield before
72
+ unavailable!("selected repository changed during read") unless before == selection(cwd)
73
+ value
74
+ rescue SystemCallError, IOError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError => error
75
+ unavailable!("selected repository cannot be observed (#{error.class})")
76
+ end
77
+
78
+ def selection(cwd)
79
+ directory!(cwd)
80
+ directory!(@common)
81
+ worktree = cwd
82
+ loop do
83
+ break if File.exist?(File.join(worktree, ".git")) || File.symlink?(File.join(worktree, ".git"))
84
+ unavailable!("worker checkout is unavailable") if worktree == @root
85
+ worktree = File.dirname(worktree)
86
+ end
87
+ marker = File.join(worktree, ".git")
88
+ metadata = {}
89
+ git_dir = if File.lstat(marker).directory?
90
+ directory!(marker)
91
+ unavailable!("worker belongs to another project") unless marker == @common
92
+ marker
93
+ else
94
+ bytes = regular!(marker, metadata, 4096)
95
+ match = bytes.match(/\Agitdir: ([^\n\0]+)\n?\z/)
96
+ unavailable!("worker Git pointer is invalid") unless match
97
+ selected = File.expand_path(match[1], worktree)
98
+ unless File.dirname(selected) == File.join(@common, "worktrees")
99
+ unavailable!("worker Git pointer leaves installed project")
100
+ end
101
+ directory!(selected)
102
+ common = regular!(File.join(selected, "commondir"), metadata, 4096).strip
103
+ unavailable!("worker common directory differs") unless File.expand_path(common, selected) == @common
104
+ back = regular!(File.join(selected, "gitdir"), metadata, 4096).strip
105
+ unavailable!("worker Git backpointer differs") unless back == marker
106
+ selected
107
+ end
108
+ @entries = 0
109
+ %w[objects refs].each { |name| storage!(File.join(@common, name)) }
110
+ %w[commondir shallow reftable info/grafts objects/info/alternates objects/info/http-alternates].each do |name|
111
+ path = File.join(@common, name)
112
+ unavailable!("worker object storage is redirected") if File.exist?(path) || File.symlink?(path)
113
+ end
114
+ regular!(File.join(git_dir, "HEAD"), metadata, 4096)
115
+ packed = File.join(@common, "packed-refs")
116
+ storage!(packed) if File.exist?(packed) || File.symlink?(packed)
117
+ local_refs = File.join(git_dir, "refs")
118
+ storage!(local_refs) if git_dir != @common && (File.exist?(local_refs) || File.symlink?(local_refs))
119
+ config!(File.join(@common, "config"), metadata)
120
+ local = File.join(git_dir, "config.worktree")
121
+ config!(local, metadata) if File.exist?(local) || File.symlink?(local)
122
+ {"worktree" => worktree, "git_dir" => git_dir, "metadata" => metadata,
123
+ "directories" => [cwd, worktree, git_dir, @common].to_h { |path| [path, identity(File.lstat(path))] }}
124
+ end
125
+
126
+ def config!(path, metadata)
127
+ bytes = regular!(path, metadata, 65_536)
128
+ output = run(["config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"], stdin: bytes)
129
+ output.split("\0").each do |entry|
130
+ key, value = entry.split("\n", 2)
131
+ if key.start_with?("include.", "includeif.", "extensions.") || key == "core.worktree" ||
132
+ key == "core.repositoryformatversion" && value != "0"
133
+ unavailable!("worker Git configuration redirects project storage")
134
+ end
135
+ end
136
+ end
137
+
138
+ def git(selected, *argv, allowed_status: [0], result: false)
139
+ captured = run_result(["--git-dir=#{selected.fetch('git_dir')}", "--work-tree=#{selected.fetch('worktree')}",
140
+ "-c", "safe.directory=#{selected.fetch('worktree')}", "-c", "core.hooksPath=/dev/null",
141
+ "-c", "core.fsmonitor=false", "-c", "protocol.allow=never", *argv],
142
+ environment: {"GIT_COMMON_DIR" => @common, "GIT_OBJECT_DIRECTORY" => File.join(@common, "objects")}, allowed_status: allowed_status)
143
+ result ? captured : captured.stdout
144
+ end
145
+
146
+ def run(argv, stdin: "", environment: {})
147
+ run_result(argv, stdin: stdin, environment: environment).stdout
148
+ end
149
+
150
+ def run_result(argv, stdin: "", environment: {}, allowed_status: [0])
151
+ result = Ace::Herdr::Molecules::BoundedProcess.call(["/usr/bin/git", "--no-pager", *argv],
152
+ environment: Ace::Assign::Molecules::InstalledJournalRead::ENVIRONMENT.merge(environment), chdir: "/",
153
+ stdin_data: stdin, timeout_s: remaining, output_limit: 65_536, cleanup_group: true)
154
+ unavailable!("worker Git read failed or exceeded its bound") unless allowed_status.include?(result.status.exitstatus) && !result.oversized
155
+ result
156
+ end
157
+
158
+ def regular!(path, metadata, limit)
159
+ directory!(File.dirname(path))
160
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
161
+ before = file.stat
162
+ unavailable!("worker metadata must be a bounded regular file") unless before.file? && before.size <= limit
163
+ bytes = file.read(limit + 1)
164
+ unless bytes.bytesize <= limit && stamp(before) == stamp(file.stat) && stamp(before) == stamp(File.lstat(path))
165
+ unavailable!("worker metadata changed during read")
166
+ end
167
+ metadata[path] = [stamp(before), bytes]
168
+ bytes
169
+ end
170
+ end
171
+
172
+ def directory!(path)
173
+ current = path
174
+ loop do
175
+ remaining
176
+ stat = File.lstat(current)
177
+ unavailable!("worker directory is redirected") unless stat.directory? && !stat.symlink?
178
+ break if current == "/"
179
+ current = File.dirname(current)
180
+ end
181
+ end
182
+
183
+ def storage!(path)
184
+ remaining
185
+ @entries += 1
186
+ unavailable!("worker storage inventory exceeds its bound") if @entries > Ace::Assign::Molecules::InstalledJournalRead::ENTRY_LIMIT
187
+ stat = File.lstat(path)
188
+ unavailable!("worker storage is redirected") unless !stat.symlink? && (stat.file? || stat.directory?)
189
+ Dir.each_child(path) { |name| storage!(File.join(path, name)) } if stat.directory?
190
+ end
191
+
192
+ def absolute!(path)
193
+ unless path.is_a?(String) && path.bytesize.between?(1, 4096) && !path.include?("\0") &&
194
+ path.start_with?("/") && File.expand_path(path) == path
195
+ unavailable!("worker path is invalid")
196
+ end
197
+ end
198
+
199
+ def within?(path, parent) = path == parent || path.start_with?(parent + "/")
200
+ def identity(stat) = [stat.dev, stat.ino]
201
+ def stamp(stat) = [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
202
+
203
+ def remaining
204
+ unless @deadline.is_a?(Numeric) && @deadline.finite? && (left = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
205
+ unavailable!("worker observation deadline elapsed")
206
+ end
207
+ left
208
+ end
209
+
210
+ def unavailable!(reason)
211
+ raise Ace::Assign::AttemptErrors::EvidenceUnavailable, reason
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "open3"
4
+ require "ace/herdr/molecules/bounded_process"
5
5
  require "socket"
6
6
  require "timeout"
7
7
  require "time"
@@ -40,7 +40,8 @@ module Ace
40
40
  return nil unless valid_response?(response, request)
41
41
  {"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
42
42
  "executor_uid" => current.fetch("executor_uid")}
43
- rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError
43
+ rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError,
44
+ Ace::Herdr::Molecules::BoundedProcess::PostLaunchError
44
45
  # Transport failures after the claim (broken pipe, connection
45
46
  # reset, socket loss) leave the effect unknown: uncertainty, not a
46
47
  # classified crash, is the honest outcome.
@@ -49,49 +50,18 @@ module Ace
49
50
 
50
51
  private
51
52
 
52
- # Handler output is bounded DURING execution: a noisy handler is
53
- # killed and reaped once its response exceeds the receipt limit
54
- # instead of being buffered without bound in this process.
53
+ # The existing process owner bounds both streams and the whole run,
54
+ # and cleans the owned process group on timeout and normal exit.
55
55
  def invoke_local(operation, request, input)
56
56
  unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
57
57
  raise SecurityError, "current OS identity is not the configured executor"
58
58
  end
59
- out = nil
60
- status = nil
61
- Open3.popen3(*operation.fetch("argv")) do |stdin, stdout, stderr, waiter|
62
- pid = waiter.pid
63
- begin
64
- Timeout.timeout(30) do
65
- stdin.write(JSON.generate({"request" => request, "input" => input}))
66
- stdin.close
67
- errors = Thread.new { bounded_read(stderr, 8192) }
68
- out = bounded_read(stdout, 16 * 1024 + 1)
69
- errors.join
70
- Process.kill("KILL", pid) if out && out.bytesize > 16 * 1024
71
- status = waiter.value
72
- end
73
- rescue Timeout::Error
74
- Process.kill("KILL", pid) rescue nil
75
- waiter.value
76
- raise
77
- end
78
- end
79
- out if out && out.bytesize <= 16 * 1024 && status&.success?
80
- end
81
-
82
- def bounded_read(io, limit)
83
- io.read(limit).to_s
84
- rescue IOError, Errno::EBADF
85
- ""
86
- ensure
87
- begin
88
- io.close
89
- rescue IOError, Errno::EBADF
90
- nil
91
- end
59
+ result = Ace::Herdr::Molecules::BoundedProcess.call(operation.fetch("argv"),
60
+ stdin_data: JSON.generate({"request" => request, "input" => input}),
61
+ timeout_s: 30, output_limit: 16 * 1024, stderr_limit: 8192, cleanup_group: true)
62
+ result.stdout if result.status.success? && !result.oversized
92
63
  end
93
64
 
94
-
95
65
  def invoke_unix(operation, request, input)
96
66
  path = operation.fetch("socket_path")
97
67
  stat = File.lstat(path)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../errors"
4
+ require_relative "../atoms/service_input"
4
5
  require "ace/assign/errors"
5
6
  require "pathname"
6
7
  require "time"
@@ -24,10 +25,14 @@ module Ace
24
25
  end
25
26
  end
26
27
 
27
- # The trusted installation document as loaded; the ordinary producer
28
- # binds its configuration digest to these exact bytes.
28
+ # The trusted installation document as loaded. Decisions are mutable;
29
+ # all other configuration remains bound for the receiver lifetime.
29
30
  attr_reader :document
30
31
 
32
+ def self.configuration_digest(document)
33
+ Atoms::ServiceInput.digest(document.except("authorizations"))
34
+ end
35
+
31
36
  def operation!(name, project:, service_id:)
32
37
  raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
33
38
  operation = @operations[name]
@@ -115,7 +120,7 @@ module Ace
115
120
  # admission flag is rollback: new admission is unavailable while
116
121
  # canonical records and read-only status stay preserved.
117
122
  ORDINARY_SECTION_KEYS = %w[projects].freeze
118
- ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission].freeze
123
+ ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission journal_repository journal_ref].freeze
119
124
  ORDINARY_AUTHORIZATION_KEYS = %w[operation project_id assignment_id attempt_id queue_entry
120
125
  input_digest forge pr_url head_repository head_ref candidate_head base_repository
121
126
  base_ref expected_base_head expires_at].freeze
@@ -160,7 +165,7 @@ module Ace
160
165
  end
161
166
  unless entry["receiver_id"].is_a?(String) && entry["receiver_id"].match?(ID) &&
162
167
  entry["socket_path"].is_a?(String) && entry["socket_path"].start_with?("/") &&
163
- entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"] >= 0 &&
168
+ entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"].positive? &&
164
169
  entry["principal"].is_a?(String) && !entry["principal"].empty?
165
170
  raise Ace::Lab::InvalidConfigurationError, "ordinary receiver mapping is incomplete"
166
171
  end
@@ -168,6 +173,12 @@ module Ace
168
173
  unless %w[enabled disabled].include?(admission)
169
174
  raise Ace::Lab::InvalidConfigurationError, "ordinary admission flag is invalid"
170
175
  end
176
+ repository, ref = entry.values_at("journal_repository", "journal_ref")
177
+ unless repository.is_a?(String) && repository.start_with?("/") && !repository.include?("\0") &&
178
+ File.expand_path(repository) == repository &&
179
+ ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
180
+ raise Ace::Lab::InvalidConfigurationError, "ordinary journal selection is invalid"
181
+ end
171
182
  end
172
183
  true
173
184
  end
@@ -65,7 +65,7 @@ module Ace
65
65
  service_policy: policy, deployment_history: history)
66
66
  router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
67
67
  Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
68
- lifecycle: router, composition: "services", hitl_service: publication_hitl(endcap, journals))
68
+ lifecycle: router, composition: "services", services: [publication_hitl(endcap, journals)])
69
69
  end
70
70
 
71
71
  private
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "ace/assign/authority/ordinary_client"
5
+ require "ace/assign/authority/ordinary_lifecycle"
6
+ require "ace/assign/organisms/prepared_work_builder"
7
+ require_relative "../molecules/ordinary_owner_config"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Organisms
12
+ # File inputs retain the exact mutation and bytes across response loss.
13
+ # Only Assign's installed owner registers, advances and accepts execution.
14
+ class OrdinaryAssignmentClient
15
+ A = Ace::Assign::Authority
16
+ REQUEST_KEYS = %w[version project_id operation mutation_id params parts].freeze
17
+
18
+ def initialize(project:, config: nil, client_factory: nil)
19
+ @project = project
20
+ @config = config || Molecules::OrdinaryOwnerConfig.load(project: project)
21
+ unless @config.is_a?(Molecules::OrdinaryOwnerConfig) && @config.project == project
22
+ raise ArgumentError, "ordinary assignment project selection differs"
23
+ end
24
+ @client_factory = client_factory || ->(**options) { A::OrdinaryClient.new(**options) }
25
+ end
26
+
27
+ # Preparation produces input only, so it is also available on the host.
28
+ # Reusing an existing output directory never rebuilds or resubmits work.
29
+ def self.prepare(project:, task:, pane:, output:, dependency_reports: [])
30
+ unless project.is_a?(String) && Molecules::OrdinaryOwnerConfig::ID.match?(project) &&
31
+ pane.is_a?(String) && Molecules::OrdinaryOwnerConfig::PANE.match?(pane)
32
+ raise ArgumentError, "preparation requires project and native pane selectors"
33
+ end
34
+ directory = create_output(output)
35
+ prepared = Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: directory)
36
+ .call(task_ref: task, project_id: project, dependency_reports: dependency_reports)
37
+ definition = JSON.parse(prepared.fetch("definition_bytes"))
38
+ reference = definition.fetch("prepared_work")
39
+ request = {"version" => 1, "project_id" => project, "operation" => "register_assignment",
40
+ "mutation_id" => SecureRandom.hex(16), "params" => {
41
+ "assignment_id" => prepared.fetch("assignment_id"), "pane" => pane, "expected_generation" => 0,
42
+ "definition_digest" => Digest::SHA256.hexdigest(prepared.fetch("definition_bytes")),
43
+ "prepared_head" => prepared.fetch("head"), "prepared_tree" => prepared.fetch("tree"),
44
+ "manifest_sha256" => reference.fetch("manifest_sha256")},
45
+ "parts" => [{"file" => "prepared.bundle", "bytes" => prepared.fetch("bytes"), "sha256" => prepared.fetch("sha256")}]}
46
+ write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
47
+ write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
48
+ write_file(directory, "request.json", JSON.generate(request) + "\n")
49
+ {"project_id" => project, "assignment_id" => prepared.fetch("assignment_id"),
50
+ "scope" => prepared.fetch("scope"), "request" => File.join(directory, "request.json"), "prepared" => true}
51
+ end
52
+
53
+ def request(input:, output: nil)
54
+ request = JSON.parse(self.class.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
55
+ unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS.sort && request["version"].is_a?(Integer) && request["version"] == 1 &&
56
+ request["project_id"] == @project && A::OrdinaryLifecycle::OPERATIONS.include?(request["operation"]) &&
57
+ request["params"].is_a?(Hash) && !request["params"].key?("transfer") && request["parts"].is_a?(Array)
58
+ raise ArgumentError, "retained ordinary request selection is invalid"
59
+ end
60
+ operation = request.fetch("operation")
61
+ read = %w[registration_status assignment_snapshot result_status result_snapshot].include?(operation)
62
+ mutation = request.fetch("mutation_id")
63
+ unless read ? mutation.nil? : mutation.is_a?(String) && Ace::Assign::Molecules::JournalMutation::ID.match?(mutation)
64
+ raise ArgumentError, "retained mutation ID must be explicit; read operations require null"
65
+ end
66
+ transfer = A::OrdinaryLifecycle::TRANSFER_OPERATIONS[operation]
67
+ download = transfer && transfer.fetch(:direction) == :download
68
+ upload = transfer && transfer.fetch(:direction) == :upload
69
+ unless (!!output == !!download) && (request["parts"].empty? || upload)
70
+ raise ArgumentError, "download requires a new --output directory; only uploads accept parts"
71
+ end
72
+ parts = upload && read_parts(request.fetch("parts"), input, transfer.fetch(:purpose))
73
+ # No local metadata is interpreted as successful execution. Download
74
+ # files are merely a projection of the authenticated canonical reply.
75
+ directory = download && self.class.create_output(output)
76
+ client = @client_factory.call(project_id: @project, service: @config.service,
77
+ verify_selection: @config.method(:verify_owner!), scratch_root: directory || nil)
78
+ reply = client.call(operation, request.fetch("params"), mutation_id: request.fetch("mutation_id"),
79
+ upload_parts: parts || nil, download: !!download, purpose: transfer && transfer.fetch(:purpose))
80
+ envelope = {"status" => "ok", "data" => reply.data, "transport" => {"replayed" => reply.replayed}}
81
+ if download
82
+ files = reply.parts.each_with_index.map do |bytes, index|
83
+ name = format("part-%02d.bytes", index)
84
+ self.class.write_file(directory, name, bytes)
85
+ {"file" => File.join(directory, name), "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
86
+ end
87
+ envelope["files"] = files
88
+ self.class.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
89
+ end
90
+ envelope
91
+ rescue JSON::ParserError
92
+ raise ArgumentError, "retained request is not valid bounded JSON"
93
+ end
94
+
95
+ def self.read_file(path, limit)
96
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
97
+ before = file.stat
98
+ raise ArgumentError, "retained input is not a bounded regular file" unless before.file? && before.size.between?(0, limit)
99
+ bytes = (file.read(limit + 1) || "").b
100
+ after, selected = file.stat, File.lstat(path)
101
+ identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
102
+ unless bytes.bytesize == before.size && identity.call(before) == identity.call(after) &&
103
+ identity.call(after) == identity.call(selected) && !selected.symlink?
104
+ raise ArgumentError, "retained input changed during read"
105
+ end
106
+ bytes.freeze
107
+ end
108
+ end
109
+
110
+ def self.create_output(path)
111
+ raise ArgumentError, "output must be a new directory path" unless path.is_a?(String) && !path.empty?
112
+ directory = File.expand_path(path)
113
+ Dir.mkdir(directory, 0o700)
114
+ A::PrivateDirectory.verify!(directory)
115
+ directory
116
+ end
117
+
118
+ def self.write_file(directory, name, bytes)
119
+ File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
120
+ file.write(bytes)
121
+ file.flush
122
+ file.fsync
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def read_parts(entries, input, purpose)
129
+ total, count, per_part = A::TransferCodec::LIMITS.fetch(purpose)
130
+ unless entries.size.between?(1, count) && entries.all? { |entry|
131
+ entry.is_a?(Hash) && entry.keys.sort == %w[bytes file sha256] && entry["file"].is_a?(String) &&
132
+ entry["file"].bytesize.between?(1, 4096) && !entry["file"].include?("\0") &&
133
+ entry["bytes"].is_a?(Integer) && entry["bytes"].between?(0, per_part) &&
134
+ entry["sha256"].is_a?(String) && A::TransferCodec::SHA256.match?(entry["sha256"])
135
+ } && entries.sum { |entry| entry.fetch("bytes") } <= total
136
+ raise ArgumentError, "retained upload inventory is invalid"
137
+ end
138
+ entries.map do |entry|
139
+ bytes = self.class.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
140
+ unless bytes.bytesize == entry.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == entry.fetch("sha256")
141
+ raise ArgumentError, "retained upload bytes changed; preserve the original mutation"
142
+ end
143
+ bytes
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/assign/authority/ordinary_lifecycle"
4
+ require "ace/assign/authority/ordinary_server"
5
+ require "ace/assign/authority/router"
6
+ require "ace/hitl/providers/lab"
7
+ require "ace/hitl/lifecycle/service"
8
+ require "ace/hitl/lifecycle/tls_authority"
9
+ require "ace/hitl/lifecycle/tls_service"
10
+ require_relative "../molecules/ordinary_owner_admission"
11
+ require_relative "../molecules/ordinary_listener"
12
+ require_relative "ordinary_merge_receiver"
13
+
14
+ module Ace
15
+ module Lab
16
+ module Organisms
17
+ # Ordinary delivery has one installed journal writer and exclusion owner.
18
+ # Existing Assign, HITL and merge protocols share that process lifetime;
19
+ # no requester cache or daemon working directory chooses accepted state.
20
+ class OrdinaryAuthorityComposition
21
+ def initialize(receiver_config:, owner_config: nil)
22
+ unless receiver_config.is_a?(Molecules::OrdinaryReceiverConfig)
23
+ raise ArgumentError, "ordinary composition needs installed receiver selection"
24
+ end
25
+ @receiver = receiver_config
26
+ @config = owner_config || Molecules::OrdinaryOwnerConfig.load(project: @receiver.project)
27
+ unless @config.is_a?(Molecules::OrdinaryOwnerConfig)
28
+ raise ArgumentError, "ordinary composition needs installed assignment selection"
29
+ end
30
+ end
31
+
32
+ def build
33
+ @config.verify_listener!
34
+ policy_document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
35
+ verify_join!(policy_document)
36
+ admission = Molecules::OrdinaryOwnerAdmission.new(config: @config)
37
+ reader = nil
38
+ journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: @config.journal.fetch("repository"),
39
+ ref: @config.journal.fetch("ref"), checkout_root: @config.journal.fetch("checkout_root"),
40
+ evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) })
41
+ reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
42
+ exclusion = Ace::Assign::Molecules::LifecycleExclusion.new(root: @config.lifecycle_root)
43
+ coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(cache_base: @config.assignment_root,
44
+ repo_root: @config.journal.fetch("repository"), journal: journal, lifecycle_exclusion: exclusion,
45
+ recorded_binding_observer: admission.method(:recorded_binding!))
46
+ lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
47
+ admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root)
48
+ router = Ace::Assign::Authority::Router.new(handlers: [lifecycle])
49
+ merge = OrdinaryMergeReceiver.new(config: @receiver, coordinator: coordinator,
50
+ policy_document: policy_document, owner_guard: -> { @config.selected_principal!(@receiver.principal_uid) },
51
+ merge_factory: ->(document) {
52
+ Molecules::OrdinaryMergeRead.new(repo_path: @receiver.repo_path, base_ref: document.fetch("base_ref"),
53
+ root: @receiver.repo_path, common_dir: @config.worktrees.fetch("common_dir"))
54
+ })
55
+ listener = Molecules::OrdinaryListener.new(socket_path: @receiver.socket_path,
56
+ peer_uid: @receiver.principal_uid, socket_gid: @receiver.socket_gid, handler: merge,
57
+ logger: ->(message) { warn message })
58
+ Ace::Assign::Authority::OrdinaryServer.new(admission: admission, lifecycle: router,
59
+ services: hitl_services(coordinator) + [listener])
60
+ end
61
+
62
+ private
63
+
64
+ def verify_join!(document)
65
+ configured = Molecules::ServicePolicy.new(document).ordinary_receiver!(@config.project)
66
+ expected = {"receiver_id" => @receiver.receiver_id, "socket_path" => @receiver.socket_path,
67
+ "receiver_uid" => @config.service.fetch("uid"), "principal" => @receiver.principal,
68
+ "journal_repository" => @config.journal.fetch("repository"), "journal_ref" => @config.journal.fetch("ref")}
69
+ unless @receiver.project == @config.project && @receiver.receiver_uid == @config.service.fetch("uid") &&
70
+ configured && configured.slice(*expected.keys) == expected &&
71
+ @config.participants.key?(@receiver.principal_uid.to_s) &&
72
+ ![@config.service.fetch("socket_path"), @config.native.fetch("socket_path"), @config.hitl.fetch("socket_path")].include?(@receiver.socket_path)
73
+ raise InvalidConfigurationError, "ordinary receiver and assignment owner selections differ"
74
+ end
75
+ end
76
+
77
+ def hitl_services(coordinator)
78
+ selected = @config.hitl
79
+ policy = Ace::Hitl::Providers::Lab.grants_policy(grants_path: selected.fetch("grants_path"))
80
+ unless policy.service_uid == @config.service.fetch("uid")
81
+ raise InvalidConfigurationError, "ordinary HITL service principal differs from assignment owner"
82
+ end
83
+ guard = lambda do
84
+ @config.verify_owner!
85
+ unless policy.service_uid == @config.service.fetch("uid")
86
+ raise Ace::Hitl::Lifecycle::BindingError, "installed HITL service principal changed"
87
+ end
88
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => error
89
+ raise Ace::Hitl::Lifecycle::BindingError, error.message
90
+ end
91
+ binding = Ace::Hitl::Providers::Lab::AssignmentBinding.new(coordinator: coordinator,
92
+ project: @config.project, owner_guard: guard)
93
+ # Unix requesters and the existing Hermes mTLS transport share the
94
+ # same owner and one in-memory OTP vault. No second daemon opens the
95
+ # journal and no synthetic Unix principal represents a certificate.
96
+ vault = Ace::Hitl::Lifecycle::OtpVault::MemoryVault.new
97
+ services = [Ace::Hitl::Lifecycle::Service.new(root: selected.fetch("root"),
98
+ socket_path: selected.fetch("socket_path"), group: selected.fetch("group"),
99
+ binding: binding, policy: policy, vault: vault)]
100
+ if selected.key?("tls_authority")
101
+ authority = Ace::Hitl::Lifecycle::TlsAuthority.load(selected.fetch("tls_authority"))
102
+ verify_tls_identity!(authority)
103
+ services << Ace::Hitl::Lifecycle::TlsService.new(root: selected.fetch("root"),
104
+ binding: binding, project: @config.project, credential: authority.credential,
105
+ ssl_context: authority.ssl_context!, listen: authority.listen, port: authority.port,
106
+ group: selected.fetch("group"), vault: vault, decision_policy: policy)
107
+ end
108
+ services
109
+ end
110
+
111
+ def verify_tls_identity!(authority)
112
+ unless authority.project_id == @config.project &&
113
+ Etc.getpwnam(authority.service_user).uid == @config.service.fetch("uid") &&
114
+ Etc.getgrnam(authority.service_group).gid == @config.service.fetch("gid")
115
+ raise InvalidConfigurationError, "ordinary HITL TLS project or service identity differs"
116
+ end
117
+ rescue ArgumentError
118
+ raise InvalidConfigurationError, "ordinary HITL TLS service account is unavailable"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end