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,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/assign/authority/ordinary_peer"
4
+ require "ace/assign/molecules/execution_identity_resolver"
5
+ require "ace/herdr"
6
+ require_relative "ordinary_owner_config"
7
+ require_relative "ordinary_native_read"
8
+ require_relative "ordinary_worktree_read"
9
+
10
+ module Ace
11
+ module Lab
12
+ module Molecules
13
+ # The source-selected ordinary peer policy. Native selectors are hints;
14
+ # only actual ancestry in the installed project's Herdr grants attribution.
15
+ class OrdinaryOwnerAdmission
16
+ def initialize(config:, peer: Ace::Assign::Authority::OrdinaryPeer.new, runtime: nil)
17
+ raise ArgumentError, "ordinary owner requires validated installed selection" unless config.is_a?(OrdinaryOwnerConfig)
18
+ @config, @peer = config, peer
19
+ @runtime = runtime
20
+ end
21
+
22
+ def service = @config.service
23
+ def verify_listener!
24
+ @config.verify_listener!
25
+ OrdinaryNativeRead.new(config: @config, deadline: default_deadline, peer: @peer).preflight!
26
+ end
27
+ def peer(socket, deadline:) = @peer.capture(socket, deadline: deadline)
28
+
29
+ def authorize!(request:, peer:, deadline: default_deadline)
30
+ @peer.live!(peer, deadline: deadline)
31
+ unless request.fetch("project_id") == @config.project
32
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary request belongs to another project"
33
+ end
34
+ @config.participant!(peer)
35
+ pane = request.fetch("params", {})["pane"]
36
+ # Reads need project visibility only. Every mutating lifecycle route
37
+ # validates a pane; supervisor authority additionally requires actual
38
+ # caller ancestry in one of the root-installed native placements.
39
+ return :worker unless pane
40
+ identity!(peer: peer, pane: pane, deadline: deadline).role == "coordinator" ? :supervisor : :worker
41
+ end
42
+
43
+ def identity!(peer:, pane:, deadline: default_deadline)
44
+ observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: false).first
45
+ end
46
+
47
+ # The caller's claimed head is only a comparison. The accepted start
48
+ # uses the observed worker's checkout and process, never daemon cwd.
49
+ def execution!(peer:, pane:, head:, deadline: default_deadline)
50
+ unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
51
+ raise ArgumentError, "ordinary start HEAD is invalid"
52
+ end
53
+ identity, cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
54
+ checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
55
+ common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
56
+ after, after_cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
57
+ unless after == identity && after_cwd == cwd && checkout.fetch("head") == head
58
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary worker process, working directory or HEAD changed"
59
+ end
60
+ {identity: identity, checkout: checkout}
61
+ end
62
+
63
+ # Read the original canonical native owner for a trusted consumer.
64
+ # The binding must come from accepted history, never requester fields.
65
+ # This observes ownership; it does not grant the target's privileges to
66
+ # the caller or manufacture a kernel-authenticated connection identity.
67
+ def recorded_binding!(binding:, caller_pid: nil, deadline: default_deadline)
68
+ observe_recorded_binding(binding, caller_pid, deadline).first
69
+ end
70
+
71
+ def recorded_execution!(binding:, head:, deadline: default_deadline)
72
+ before, cwd = observe_recorded_binding(binding, nil, deadline)
73
+ checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
74
+ common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
75
+ after, after_cwd = observe_recorded_binding(binding, nil, deadline)
76
+ unless before == after && cwd == after_cwd && checkout.fetch("head") == head
77
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded worker or HEAD changed"
78
+ end
79
+ {runtime_binding: after, checkout: checkout}
80
+ end
81
+
82
+ private
83
+
84
+ def observe_recorded_binding(binding, caller_pid, deadline)
85
+ @config.verify_selection!
86
+ uid = binding.fetch("process_identity").fetch("uid")
87
+ participant = @config.selected_principal!(uid)
88
+ unless participant && binding["runtime"] == participant.fetch("runtime") && binding["session"] == participant.fetch("workspace")
89
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded participant is no longer selected"
90
+ end
91
+ native, runtime = native_runtime(deadline)
92
+ actual = runtime.process_binding(pane: binding.fetch("pane"), caller_pid: caller_pid || binding.fetch("process_identity").fetch("pid"))
93
+ unless actual == binding
94
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded native owner is unavailable or changed"
95
+ end
96
+ cwd = native.process_cwd(binding.fetch("process_identity").fetch("pid"))
97
+ @config.selected_principal!(uid)
98
+ [actual, cwd]
99
+ end
100
+
101
+ def native_runtime(deadline)
102
+ return [@runtime, @runtime] if @runtime
103
+ observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
104
+ native = OrdinaryNativeRead.new(config: @config, deadline: deadline, peer: @peer)
105
+ native.preflight!
106
+ [native, Ace::Herdr::Organisms::RuntimeAdapter.new(executor: native, env: {},
107
+ process_identity: Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation))]
108
+ end
109
+
110
+ def observe_identity(peer:, pane:, deadline:, with_cwd:)
111
+ @peer.live!(peer, deadline: deadline)
112
+ participant = @config.participant!(peer)
113
+ unless pane.is_a?(String) && pane.bytesize.between?(1, 128) && !pane.include?("\0")
114
+ raise ArgumentError, "ordinary native pane selector is invalid"
115
+ end
116
+ native, runtime = native_runtime(deadline)
117
+ binding = runtime.process_binding(pane: pane, caller_pid: peer.fetch("pid"))
118
+ unless binding && binding["runtime"] == participant.fetch("runtime") &&
119
+ binding["session"] == participant.fetch("workspace") && binding["pane"] == pane &&
120
+ binding.dig("process_identity", "uid") == peer.fetch("uid")
121
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary native owner is unavailable or differs"
122
+ end
123
+ @peer.live!(peer, deadline: deadline)
124
+ @config.participant!(peer)
125
+ # Ordinary native agents may share an OS account. Preserve that actor
126
+ # for HITL attribution; privilege follows observed native placement,
127
+ # not a claimed role or an invented per-agent account alias.
128
+ role = participant.fetch("supervisor_panes").include?(binding.fetch("pane")) ? "coordinator" : "worker"
129
+ identity = Ace::Assign::Molecules::ExecutionIdentityResolver::Identity.new(
130
+ actor: participant.fetch("actor"), role: role, runtime: participant.fetch("runtime"),
131
+ adapter: "ordinary", process_pid: binding.fetch("process_identity").fetch("pid"), runtime_binding: binding)
132
+ [identity, with_cwd ? native.process_cwd(identity.process_pid) : nil]
133
+ end
134
+
135
+ def default_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "digest"
6
+ require "etc"
7
+ require "ace/assign/authority/private_directory"
8
+ require "ace/assign/authority/protected_assignment_context"
9
+ require "ace/assign/molecules/installed_journal_read"
10
+ require "ace/runtime/molecules/protected_artifact_set"
11
+ require "ace/hitl/lifecycle/store"
12
+ require_relative "../errors"
13
+
14
+ module Ace
15
+ module Lab
16
+ module Molecules
17
+ # Placement and principals only. Source selects lifecycle operations.
18
+ # Root installs this independently of requester worktrees/config cascades.
19
+ class OrdinaryOwnerConfig
20
+ PATH = "/etc/lab/ace-lab/assign-owners.yml"
21
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
22
+ PROJECT_KEYS = %w[service journal assignment_root lifecycle_root runtime_binary native worktrees participants hitl].freeze
23
+ SERVICE_KEYS = %w[uid gid socket_path state_root].freeze
24
+ JOURNAL_KEYS = %w[repository ref checkout_root].freeze
25
+ PARTICIPANT_KEYS = %w[gid actor runtime workspace supervisor_panes].freeze
26
+ PANE = /\A[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}\z/
27
+ NATIVE_KEYS = %w[socket_path socket_identity server_identity version protocol].freeze
28
+ HITL_KEYS = %w[root socket_path group grants_path].freeze
29
+ HITL_OPTIONAL_KEYS = %w[tls_authority].freeze
30
+
31
+ def self.load(project:, path: PATH)
32
+ reader = Ace::Runtime::Molecules::ProtectedArtifactSet.new(file_limit: 65_536, total_limit: 65_536)
33
+ bytes = reader.with do |held|
34
+ content, = held.read_path!(path)
35
+ held.verify_unchanged!
36
+ content
37
+ end
38
+ new(YAML.safe_load(bytes, aliases: false), project: project, source: path)
39
+ rescue Psych::Exception, Ace::Runtime::Error
40
+ raise InvalidConfigurationError, "ordinary assignment owner configuration is unavailable: #{path}"
41
+ end
42
+
43
+ attr_reader :project, :service, :journal, :assignment_root, :lifecycle_root,
44
+ :runtime_binary, :native, :worktrees, :participants, :hitl, :source
45
+
46
+ def initialize(document, project:, source:)
47
+ unless document.is_a?(Hash) && document.keys.sort == %w[projects version] && document["version"] == 1 &&
48
+ document["projects"].is_a?(Hash) && document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } &&
49
+ project.is_a?(String) && ID.match?(project)
50
+ invalid!
51
+ end
52
+ selected = document.fetch("projects")[project]
53
+ validate_principal_separation!(document.fetch("projects"))
54
+ invalid! unless selected.is_a?(Hash) && selected.keys.sort == PROJECT_KEYS.sort
55
+ invalid! unless absolute?(source)
56
+ @project, @source = project.dup.freeze, source.dup.freeze
57
+ # Validate a detached immutable copy; callers cannot mutate trusted selection.
58
+ selected = JSON.parse(JSON.generate(selected))
59
+ @service, @journal, @participants = selected.values_at("service", "journal", "participants")
60
+ invalid! unless @service.is_a?(Hash) && @service.keys.sort == SERVICE_KEYS.sort &&
61
+ %w[uid gid].all? { |key| uid?(@service[key]) } &&
62
+ %w[socket_path state_root].all? { |key| absolute?(@service[key]) }
63
+ invalid! unless @journal.is_a?(Hash) && @journal.keys.sort == JOURNAL_KEYS.sort &&
64
+ %w[repository checkout_root].all? { |key| absolute?(@journal[key]) } &&
65
+ @journal["ref"].is_a?(String) && @journal["ref"].match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
66
+ @assignment_root, @lifecycle_root, @runtime_binary = selected.values_at("assignment_root", "lifecycle_root", "runtime_binary")
67
+ invalid! unless [@assignment_root, @lifecycle_root, @runtime_binary].all? { |path| absolute?(path) }
68
+ @native = selected.fetch("native")
69
+ invalid! unless @native.is_a?(Hash) && @native.keys.sort == NATIVE_KEYS.sort && absolute?(@native["socket_path"]) &&
70
+ @native["protocol"] == 22 && @native["protocol"].is_a?(Integer) && @native["version"].is_a?(String) &&
71
+ @native["version"].match?(/\A\d+\.\d+\.\d+\z/)
72
+ server = @native["server_identity"]
73
+ invalid! unless server.is_a?(Hash) && server.keys.sort == %w[gid host pid started_at uid] &&
74
+ %w[pid uid gid].all? { |key| uid?(server[key]) } &&
75
+ %w[host started_at].all? { |key| server[key].is_a?(String) && server[key].bytesize.between?(1, 512) && !server[key].include?("\0") }
76
+ endpoint = @native["socket_identity"]
77
+ invalid! unless endpoint.is_a?(Array) && endpoint.size == 3 && endpoint.all? { |value| value.is_a?(Integer) && value >= 0 } && endpoint.last.zero?
78
+ @worktrees = selected.fetch("worktrees")
79
+ invalid! unless @worktrees.is_a?(Hash) && @worktrees.keys.sort == %w[common_dir root] && @worktrees.values.all? { |path| absolute?(path) }
80
+ @hitl = selected.fetch("hitl")
81
+ invalid! unless @hitl.is_a?(Hash) && (HITL_KEYS - @hitl.keys).empty? &&
82
+ (@hitl.keys - HITL_KEYS - HITL_OPTIONAL_KEYS).empty? &&
83
+ %w[root socket_path grants_path].all? { |key| absolute?(@hitl[key]) } &&
84
+ (!@hitl.key?("tls_authority") || absolute?(@hitl["tls_authority"])) &&
85
+ @hitl["group"].is_a?(String) && ID.match?(@hitl["group"]) &&
86
+ [@service.fetch("socket_path"), @native.fetch("socket_path"), @hitl.fetch("socket_path")].uniq.size == 3
87
+ private_paths = [@assignment_root, @lifecycle_root, @journal.fetch("checkout_root"), @hitl.fetch("root")]
88
+ invalid! unless private_paths.all? { |path| path.start_with?(@service.fetch("state_root") + "/") } &&
89
+ private_paths.uniq.size == private_paths.size && private_paths.combination(2).none? { |a, b| a.start_with?(b + "/") || b.start_with?(a + "/") }
90
+ invalid! if @journal.fetch("repository").start_with?(@service.fetch("state_root") + "/") ||
91
+ @service.fetch("state_root").start_with?(@journal.fetch("repository") + "/") ||
92
+ @service.fetch("state_root") == @journal.fetch("repository")
93
+ invalid! unless @participants.is_a?(Hash) && !@participants.empty? && @participants.size <= 64
94
+ @participants.each do |uid, participant|
95
+ invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i) && uid.to_i != @service.fetch("uid") &&
96
+ participant.is_a?(Hash) && participant.keys.sort == PARTICIPANT_KEYS.sort && uid?(participant["gid"]) &&
97
+ participant["actor"].is_a?(String) && ID.match?(participant["actor"]) &&
98
+ participant["runtime"] == "herdr" && participant["workspace"].is_a?(String) && ID.match?(participant["workspace"]) &&
99
+ participant["supervisor_panes"].is_a?(Array) && participant["supervisor_panes"].size <= 16 &&
100
+ participant["supervisor_panes"].uniq == participant["supervisor_panes"] &&
101
+ participant["supervisor_panes"].all? { |pane| pane.is_a?(String) && PANE.match?(pane) }
102
+ end
103
+ invalid! unless @participants.values.map { |value| value.fetch("actor") }.uniq.size == @participants.size
104
+ @fingerprint = Digest::SHA256.hexdigest(Ace::Assign::Atoms::EvidenceDigest.canonical_json(selected))
105
+ freeze_value(selected)
106
+ freeze
107
+ end
108
+
109
+ def verify_selection!
110
+ current = self.class.load(project: @project, path: @source)
111
+ unless current.fingerprint == @fingerprint
112
+ raise InvalidConfigurationError, "ordinary assignment owner selection changed; restart with installed configuration"
113
+ end
114
+ true
115
+ end
116
+
117
+ def verify_listener!
118
+ verify_owner!
119
+ unless [Process.uid, Process.euid, Process.gid, Process.egid] ==
120
+ [@service.fetch("uid"), @service.fetch("uid"), @service.fetch("gid"), @service.fetch("gid")]
121
+ raise InvalidConfigurationError, "ordinary assignment owner process differs"
122
+ end
123
+ ([service.fetch("uid")] + participants.keys.map(&:to_i)).each { |uid| exclude_protected!(uid) }
124
+ [@service.fetch("state_root"), @assignment_root, @lifecycle_root, @journal.fetch("checkout_root")].each do |path|
125
+ Ace::Assign::Authority::PrivateDirectory.verify!(path)
126
+ end
127
+ verify_hitl_root!
128
+ wire = Ace::Runtime::Molecules::ProtectedSocket
129
+ wire.root_path!(@runtime_binary)
130
+ invalid! unless File.file?(@runtime_binary) && File.executable?(@runtime_binary)
131
+ invalid! unless File.lstat(@journal.fetch("repository")).uid == @service.fetch("uid")
132
+ reader = Ace::Assign::Molecules::InstalledJournalRead.new(repo_root: @journal.fetch("repository"),
133
+ ref: @journal.fetch("ref"), owner_uid: @service.fetch("uid"))
134
+ reader.with(deadline: wire.deadline(30)) { |_snapshot| true }
135
+ true
136
+ end
137
+
138
+ def verify_owner!
139
+ verify_selection!
140
+ exclude_protected!(@service.fetch("uid"))
141
+ true
142
+ end
143
+
144
+ def participant!(peer)
145
+ participant = selected_principal!(peer.fetch("uid"))
146
+ unless participant.fetch("gid") == peer.fetch("gid")
147
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant group differs"
148
+ end
149
+ participant
150
+ end
151
+
152
+ # Selection only: consumers of canonical native history have an
153
+ # observed process UID, not a new authenticated socket from that owner.
154
+ def selected_principal!(uid)
155
+ verify_owner!
156
+ exclude_protected!(uid)
157
+ participant = @participants[uid.to_s]
158
+ unless participant
159
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant is not installed for this project"
160
+ end
161
+ # HITL and the canonical coordinator use the authenticated passwd
162
+ # principal. An unrelated display label cannot replace that identity.
163
+ unless Etc.getpwuid(uid).name == participant.fetch("actor")
164
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary actor differs from installed OS principal"
165
+ end
166
+ participant
167
+ rescue ArgumentError
168
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary OS principal is unavailable"
169
+ end
170
+
171
+ protected
172
+
173
+ attr_reader :fingerprint
174
+
175
+ private
176
+
177
+ # HITL maintains its own layout (root 0711, private request/secret
178
+ # directories). The already-verified private state_root encloses it.
179
+ # Requiring a 0700 HITL leaf would reject every normal service restart.
180
+ def verify_hitl_root!
181
+ root = @hitl.fetch("root")
182
+ Ace::Runtime::Molecules::ProtectedSocket.root_path!(root, directory: true, owner: @service.fetch("uid"))
183
+ unless (File.stat(root).mode & 0o7777) == Ace::Hitl::Lifecycle::Store::ROOT_MODE
184
+ raise InvalidConfigurationError, "ordinary HITL store root must use the maintained store mode"
185
+ end
186
+ true
187
+ end
188
+
189
+ def validate_principal_separation!(projects)
190
+ invalid! unless projects.size.between?(1, 64)
191
+ owners, callers = [], []
192
+ projects.each_value do |entry|
193
+ invalid! unless entry.is_a?(Hash) && entry["service"].is_a?(Hash) &&
194
+ uid?(entry["service"]["uid"]) && entry["participants"].is_a?(Hash)
195
+ owners << entry.fetch("service").fetch("uid")
196
+ entry.fetch("participants").each_key do |uid|
197
+ invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i)
198
+ callers << uid.to_i
199
+ end
200
+ end
201
+ invalid! unless (owners & callers).empty?
202
+ end
203
+
204
+ def exclude_protected!(uid)
205
+ context = Ace::Assign::Authority::ProtectedAssignmentContext.load(uid: uid)
206
+ if context.protected_participant?
207
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "protected principal cannot use ordinary assignment authority"
208
+ end
209
+ end
210
+
211
+ def absolute?(value)
212
+ value.is_a?(String) && value.bytesize.between?(1, 4096) && !value.include?("\0") &&
213
+ value.start_with?("/") && File.expand_path(value) == value
214
+ end
215
+
216
+ def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
217
+
218
+ def freeze_value(value)
219
+ case value
220
+ when Hash then value.each { |key, item| key.freeze; freeze_value(item) }
221
+ when Array then value.each { |item| freeze_value(item) }
222
+ end
223
+ value.freeze
224
+ end
225
+
226
+ def invalid!
227
+ raise InvalidConfigurationError, "ordinary assignment owner configuration is invalid"
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../errors"
5
+ require_relative "service_policy"
6
+ require_relative "grant_resolver"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Project-scoped, explicitly filed receiver configuration (spec
12
+ # 8x3.t.xz9.4 execution clauses). The host owns the file, its
13
+ # placement, its credentials and its deployment; this loader only
14
+ # validates the exact closed shape and fails closed on anything else.
15
+ # It invents no credential and accepts no caller-selected endpoint:
16
+ # every value comes from one explicit deployment-owned YAML file.
17
+ class OrdinaryReceiverConfig
18
+ KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
19
+ repo_path policy_path execution_identity].freeze
20
+ OPTIONAL_KEYS = %w[socket_gid forge_config_path].freeze
21
+ IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
22
+ ID = ServicePolicy::ID
23
+
24
+ def self.load(path)
25
+ raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
26
+ bytes = GrantResolver.read_verified(path)
27
+ raise Ace::Lab::InvalidConfigurationError, "receiver configuration is missing" unless bytes
28
+ document = YAML.safe_load(bytes, permitted_classes: [Date], aliases: false)
29
+ new(document, source: path)
30
+ rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
31
+ raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
32
+ end
33
+
34
+ attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
35
+ :principal_uid, :repo_path, :policy_path, :execution_identity, :source, :socket_gid, :forge_config_path
36
+
37
+ def initialize(document, source: nil)
38
+ unless document.is_a?(Hash) && (document.keys - KEYS - OPTIONAL_KEYS).empty? &&
39
+ (KEYS - document.keys).empty?
40
+ raise Ace::Lab::InvalidConfigurationError,
41
+ "receiver configuration requires #{KEYS.join(', ')}; optional: #{OPTIONAL_KEYS.join(', ')}"
42
+ end
43
+ @socket_gid = uid!(document.fetch("socket_gid"), "socket_gid") if document.key?("socket_gid")
44
+ @forge_config_path = absolute!(document.fetch("forge_config_path"), "forge_config_path") if
45
+ document.key?("forge_config_path")
46
+ @source = source
47
+ @project = string!(document.fetch("project"), "project")
48
+ @receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
49
+ @principal = string!(document.fetch("principal"), "principal")
50
+ @socket_path = absolute!(document.fetch("socket_path"), "socket_path")
51
+ @repo_path = absolute!(document.fetch("repo_path"), "repo_path")
52
+ @policy_path = absolute!(document.fetch("policy_path"), "policy_path")
53
+ @receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
54
+ raise Ace::Lab::InvalidConfigurationError, "ordinary receiver must have a non-root owner" if @receiver_uid.zero?
55
+ @principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
56
+ identity = document.fetch("execution_identity")
57
+ unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
58
+ IDENTITY_KEYS.all? { |key| identity[key].is_a?(String) && !identity[key].empty? }
59
+ raise Ace::Lab::InvalidConfigurationError,
60
+ "receiver execution_identity is incomplete"
61
+ end
62
+ @execution_identity = IDENTITY_KEYS.to_h { |key| [key, identity.fetch(key)] }.freeze
63
+ unless @repo_path.start_with?("/") && File.directory?(@repo_path)
64
+ raise Ace::Lab::InvalidConfigurationError, "receiver repo_path is not a directory"
65
+ end
66
+ freeze
67
+ end
68
+
69
+ private
70
+
71
+ def string!(value, name)
72
+ unless value.is_a?(String) && !value.empty? && value.length <= 256 && value.match?(ID)
73
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} is invalid"
74
+ end
75
+ value
76
+ end
77
+
78
+ def absolute!(value, name)
79
+ unless value.is_a?(String) && value.start_with?("/") && value.length <= 1024
80
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be an absolute path"
81
+ end
82
+ value
83
+ end
84
+
85
+ def uid!(value, name)
86
+ unless value.is_a?(Integer) && value >= 0
87
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be a non-negative integer"
88
+ end
89
+ value
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -9,7 +9,7 @@ module Ace
9
9
  # Admission transport between the ordinary fixed merge producer and the
10
10
  # installed ordinary receiver: one bounded JSON document each way over
11
11
  # a configured unix stream socket with a fixed deadline. Authentication
12
- # is mutual through SO_PEERCRED — the producer verifies the receiver's
12
+ # is mutual through OS peer credentials — the producer verifies the receiver's
13
13
  # configured UID and transmits the verified local caller identity, so
14
14
  # no caller-selected credential, token or endpoint exists. This is not
15
15
  # an HTTP transport; a lost or unauthenticated response is a
@@ -33,19 +33,19 @@ module Ace
33
33
  def submit(document)
34
34
  payload = JSON.generate(document)
35
35
  raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
36
- socket = Socket.new(:UNIX, :STREAM, 0)
36
+ socket = nil
37
37
  begin
38
- socket.connect(Addrinfo.unix(@socket_path))
38
+ socket = UNIXSocket.new(@socket_path)
39
39
  verify_peer!(socket)
40
40
  write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
41
41
  response = read_response!(socket)
42
42
  rescue OrdinaryUnavailable
43
43
  raise
44
- rescue SystemCallError, IOError => e
44
+ rescue SystemCallError, IOError, SocketError, NotImplementedError => e
45
45
  raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
46
46
  ensure
47
47
  begin
48
- socket.close
48
+ socket&.close
49
49
  rescue IOError
50
50
  nil
51
51
  end
@@ -58,10 +58,7 @@ module Ace
58
58
  private
59
59
 
60
60
  def verify_peer!(socket)
61
- credentials = socket.getsockopt(:SOCKET, :PEERCRED)
62
- # struct ucred is {pid, uid, gid} on Linux; the producer binds the
63
- # receiver to its installed UID and nothing else.
64
- _peer_pid, peer_uid, = credentials.unpack("L3")
61
+ peer_uid, = socket.getpeereid
65
62
  return if peer_uid == @receiver_uid
66
63
  raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
67
64
  end