ace-lab 0.5.2 → 0.7.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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "service_policy"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Molecules
9
+ # The scoped ordinary-merge decision/admission resolver (spec 8x3.t.xz9.4,
10
+ # execution clauses). The deployment-owned YAML authorization file remains
11
+ # the sole source of truth; this resolver only makes the exact root
12
+ # authorizations natively executable inside the receiver: one per-request
13
+ # authorization selector resolves to a typed decision within the closed
14
+ # refusal-code family — or it refuses. Scoping rules:
15
+ #
16
+ # - only `operation: merge` decisions of the ordinary shape resolve;
17
+ # anything else (missing reference, shape mismatch, non-merge
18
+ # operation, any binding mismatch, expiry) is a typed
19
+ # `ordinary_merge_authorization_invalid` decision;
20
+ # - exact binding match on every closed key; there are NO wildcards,
21
+ # defaults, prefix grants or broad grants of any kind — a decision
22
+ # authorizes exactly one bound request and nothing else;
23
+ # - expiry bounds the decision itself.
24
+ #
25
+ # Per-request refresh (spec 8x3.t.xz9.5 integration handoff): a
26
+ # long-lived receiver would otherwise decide every sequential candidate
27
+ # against its boot-time snapshot of the authorization document. When a
28
+ # trusted path is installed, every decision re-reads the document
29
+ # through the trusted loader and resolves against the FRESH grants —
30
+ # a root-revoked or expired decision fails closed at the next request
31
+ # without any receiver restart. The refresh is scoped: it reloads and
32
+ # re-validates the exact-request grant only; it never broadens
33
+ # authorization beyond the resolved reference.
34
+ class OrdinaryAdmissionResolver
35
+ BINDING_KEYS = ServicePolicy::ORDINARY_BINDING_KEYS.freeze
36
+ DECISION_KEYS = ServicePolicy::ORDINARY_AUTHORIZATION_KEYS.freeze
37
+ OPERATION = "merge"
38
+
39
+ Decision = Struct.new(:authorized?, :decision, :code, :message, keyword_init: true)
40
+
41
+ # @param policy_document [Hash] boot-time trusted document (the
42
+ # configuration digest is derived from it once, upstream)
43
+ # @param path [String, nil] trusted policy path to re-read per decision
44
+ # @param loader [Proc, nil] trusted reader (default: GrantResolver);
45
+ # the only override point, for non-privileged test documents
46
+ def initialize(policy_document, path: nil, loader: nil)
47
+ unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
48
+ raise Ace::Lab::InvalidConfigurationError,
49
+ "trusted policy document must contain an authorizations mapping"
50
+ end
51
+ @authorizations = policy_document.fetch("authorizations")
52
+ @path = path
53
+ @loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
54
+ freeze
55
+ end
56
+
57
+ # Resolve one authorization selector against the exact ordinary merge
58
+ # request binding. Returns a typed Decision; `authorized?` is true only
59
+ # for an exact, unexpired, ordinary-merge-shaped match.
60
+ def decide(reference, binding)
61
+ authorizations = current_authorizations
62
+ unless reference.is_a?(String) && reference.match?(ServicePolicy::ID)
63
+ return refuse("invalid authorization reference")
64
+ end
65
+ unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
66
+ return refuse("only the ordinary merge operation is resolvable")
67
+ end
68
+ decision = authorizations[reference]
69
+ return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
70
+ unless DECISION_KEYS.all? { |key| decision.key?(key) }
71
+ return refuse("authorization reference is not an ordinary merge decision")
72
+ end
73
+ unless decision.fetch("operation") == OPERATION
74
+ return refuse("authorization does not cover the ordinary merge operation")
75
+ end
76
+ mismatch = BINDING_KEYS.find { |key| decision.fetch(key) != binding.fetch(key, nil) }
77
+ return refuse("authorization does not match the exact ordinary merge request") if mismatch
78
+ begin
79
+ expired = Time.iso8601(decision.fetch("expires_at").to_s) <= Time.now.utc
80
+ rescue ArgumentError
81
+ return refuse("authorization decision has an invalid expiry")
82
+ end
83
+ return refuse("authorization has expired") if expired
84
+ Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
85
+ end
86
+
87
+ private
88
+
89
+ # The authorizations mapping the CURRENT decision resolves against:
90
+ # the boot snapshot when no trusted path is installed, otherwise the
91
+ # freshly re-read document. A failed trusted re-read fails closed
92
+ # (InvalidConfigurationError — the unavailable family), never falls
93
+ # back to the stale snapshot.
94
+ def current_authorizations
95
+ return @authorizations unless @path
96
+ document = @loader.call(@path)
97
+ unless document.is_a?(Hash) && document["authorizations"].is_a?(Hash)
98
+ raise Ace::Lab::InvalidConfigurationError,
99
+ "invalid lab configuration: trusted authorization refresh at #{@path} " \
100
+ "did not yield an authorizations mapping"
101
+ end
102
+ document.fetch("authorizations")
103
+ end
104
+
105
+ def refuse(message)
106
+ Decision.new(authorized?: false, decision: nil,
107
+ code: "ordinary_merge_authorization_invalid", message: message)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # The fixed merge method executor (spec 8x3.t.xz9.4, merge-method
9
+ # contract). The closed request schema admits literal `method=merge`;
10
+ # the installed executor gives that method FAST-FORWARD-ONLY semantics:
11
+ # the candidate head must be a descendant of the current base head.
12
+ # A non-fast-forwardable candidate is NOT merged silently and never
13
+ # falls back to a merge commit — it reports `:non_fast_forward` so the
14
+ # receiver answers in the moved-target/needs-rebase refusal family.
15
+ # The mutation itself is one atomic compare-and-swap on the base ref
16
+ # (`git update-ref <base> <candidate> <expected>`), so concurrent
17
+ # writers can never both move the ref: the loser provably performed
18
+ # no effect. Under this strategy the resulting base head equals the
19
+ # candidate head, and the receipt joins `merge_commit ==
20
+ # resulting_target_head == merged_candidate_head` — that join is the
21
+ # receipt's fast-forward proof. Any future non-ff strategy is a
22
+ # separate reviewed contract extension, never implicit.
23
+ class OrdinaryFfMerge
24
+ STRATEGY = "ff"
25
+
26
+ Result = Struct.new(:outcome, :resulting_head, :previous_head, :reason, keyword_init: true)
27
+
28
+ def initialize(repo_path:, base_ref:)
29
+ unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
30
+ base_ref.is_a?(String) && base_ref.start_with?("refs/")
31
+ raise ArgumentError, "ordinary ff merge needs an absolute repo path and a full base ref"
32
+ end
33
+ @repo_path = repo_path
34
+ @base_ref = base_ref
35
+ end
36
+
37
+ def base_head
38
+ out, stderr, status = git("rev-parse", @base_ref)
39
+ return out.strip if status.success?
40
+ raise GitUnavailable, "base ref #{@base_ref} is unreadable: #{stderr.strip[0, 200]}"
41
+ end
42
+
43
+ def candidate_commit?(sha)
44
+ git("cat-file", "-e", "#{sha}^{commit}").last.success?
45
+ end
46
+
47
+ # True only when the candidate is a descendant of (or equal to) the
48
+ # current base head — the fast-forward precondition.
49
+ def fast_forwardable?(candidate_head)
50
+ git("merge-base", "--is-ancestor", @base_ref, candidate_head).last.success?
51
+ end
52
+
53
+ # The single effect: one compare-and-swap ref update. Returns a
54
+ # Result with outcome `:merged`, `:already_merged`, or `:race_lost`
55
+ # (the expected head moved before the swap — provably no effect from
56
+ # this invocation). Raises GitUnavailable when git itself fails.
57
+ def merge!(candidate_head, expected_head)
58
+ current = base_head
59
+ return Result.new(outcome: :already_merged, resulting_head: current,
60
+ previous_head: current, reason: nil) if current == candidate_head
61
+ unless current == expected_head
62
+ return Result.new(outcome: :race_lost, resulting_head: current,
63
+ previous_head: current, reason: "base head moved before the swap")
64
+ end
65
+ out, stderr, status = git("update-ref", @base_ref, candidate_head, expected_head)
66
+ unless status.success?
67
+ return Result.new(outcome: :race_lost, resulting_head: base_head,
68
+ previous_head: base_head, reason: "compare-and-swap lost: #{stderr.strip[0, 200]}")
69
+ end
70
+ Result.new(outcome: :merged, resulting_head: candidate_head,
71
+ previous_head: expected_head, reason: nil)
72
+ end
73
+
74
+ class GitUnavailable < ::StandardError; end
75
+
76
+ private
77
+
78
+ def git(*argv)
79
+ Open3.capture3("git", *argv, chdir: @repo_path)
80
+ rescue Errno::ENOENT => e
81
+ raise GitUnavailable, "git is unavailable: #{e.class}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require_relative "../errors"
6
+ require_relative "ordinary_socket"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # The ordinary receiver's serve-side transport (spec 8x3.t.xz9.4 serve
12
+ # entrypoint). Binds the configured unix socket and answers the exact
13
+ # framed protocol the OrdinarySocket client speaks: one bounded JSON
14
+ # document each way behind the shared handshake line. Authentication is
15
+ # MUTUAL through SO_PEERCRED: every connection's peer UID must equal
16
+ # the configured producer principal UID, and the listener itself runs
17
+ # as the configured receiver UID — the identity the client verifies on
18
+ # its side. Every identity, framing, deadline or parse failure is
19
+ # fail-closed: the peer receives the closed refusal envelope (or the
20
+ # connection closes) and nothing is ever dispatched. This is not an
21
+ # HTTP transport and there is no cross-VM fallback.
22
+ class OrdinaryListener
23
+ HANDSHAKE = OrdinarySocket::HANDSHAKE
24
+ LIMIT = OrdinarySocket::LIMIT
25
+ DEADLINE_SECONDS = 30
26
+
27
+ def initialize(socket_path:, peer_uid:, handler:, lock_path: nil, logger: nil)
28
+ unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
29
+ peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
30
+ raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
31
+ end
32
+ @socket_path = socket_path
33
+ @peer_uid = peer_uid
34
+ @handler = handler
35
+ @lock_path = lock_path || "#{@socket_path}.lock"
36
+ @logger = logger
37
+ @stopping = false
38
+ end
39
+
40
+ # Serve until #stop. Excludes a second listener through one owned
41
+ # lock file, removes only its own stale endpoint, and always removes
42
+ # the endpoint it created.
43
+ def serve
44
+ acquire_lock!
45
+ remove_stale_endpoint!
46
+ @listener = UNIXServer.new(@socket_path)
47
+ File.chmod(0o600, @socket_path)
48
+ @endpoint_identity = File.stat(@socket_path)
49
+ until @stopping
50
+ socket = @listener.accept
51
+ serve_connection(socket)
52
+ end
53
+ rescue IOError, Errno::EBADF
54
+ raise unless @stopping
55
+ ensure
56
+ @listener&.close
57
+ remove_owned_endpoint!
58
+ @lock&.flock(File::LOCK_UN)
59
+ @lock&.close
60
+ end
61
+
62
+ def stop
63
+ @stopping = true
64
+ @listener&.close
65
+ rescue IOError
66
+ nil
67
+ end
68
+
69
+ # One bounded connection: verify the peer identity, read one framed
70
+ # request, answer one framed envelope, close. Fail-closed on every
71
+ # violation; a dispatch never happens from an unverified frame.
72
+ def serve_connection(socket)
73
+ deadline = Time.now.utc + DEADLINE_SECONDS
74
+ begin
75
+ verify_peer!(socket)
76
+ submission = read_request!(socket, deadline)
77
+ response = @handler.call(submission)
78
+ write_response!(socket, response, deadline)
79
+ if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
80
+ begin
81
+ # The durable acknowledgment is already on the wire; the
82
+ # execution lifetime belongs to the canonical record, not
83
+ # the short connection. A lost lifecycle stays pending or
84
+ # uncertain and reconciles by the original reference.
85
+ @handler.execute(response.dig("data", "request_id"))
86
+ rescue StandardError
87
+ nil
88
+ end
89
+ end
90
+ rescue OrdinaryRefused => e
91
+ write_response!(socket, e.envelope, deadline)
92
+ rescue OrdinaryListenerError
93
+ # Fail closed without a fabricated envelope; the client classifies
94
+ # a lost/unreadable response as ordinary_merge_unavailable.
95
+ nil
96
+ rescue StandardError
97
+ write_response!(socket, refusal_envelope("ordinary_merge_unavailable",
98
+ "receiver failed to handle the request"), deadline)
99
+ ensure
100
+ begin
101
+ socket.close
102
+ rescue IOError
103
+ nil
104
+ end
105
+ end
106
+ end
107
+
108
+ class OrdinaryListenerError < ::StandardError; end
109
+
110
+ # A verified-connection refusal that still carries a closed envelope.
111
+ class OrdinaryRefused < OrdinaryListenerError
112
+ attr_reader :envelope
113
+
114
+ def initialize(envelope)
115
+ @envelope = envelope
116
+ super(envelope.dig("error", "message").to_s)
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # Mutual half of SO_PEERCRED: the connecting process must run as the
123
+ # configured producer principal UID. Anything else is refused before
124
+ # one byte of request is read.
125
+ def verify_peer!(socket)
126
+ credentials = socket.getsockopt(:SOCKET, :PEERCRED)
127
+ _peer_pid, peer_uid, = credentials.unpack("L3")
128
+ return if peer_uid == @peer_uid
129
+ raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
130
+ "connecting principal is not the configured ordinary overseer")
131
+ rescue SystemCallError, IOError
132
+ raise OrdinaryListenerError, "peer credentials are unavailable"
133
+ end
134
+
135
+ def read_request!(socket, deadline)
136
+ header = read_line!(socket, deadline)
137
+ match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
138
+ raise OrdinaryListenerError, "request header is invalid" unless match
139
+ length = Integer(match[1])
140
+ raise OrdinaryListenerError, "request exceeds the wire bound" if length > LIMIT
141
+ payload = read_exact!(socket, length, deadline)
142
+ JSON.parse(payload, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
143
+ rescue JSON::ParserError
144
+ raise OrdinaryRefused, refusal_envelope("ordinary_merge_invalid_request",
145
+ "request document is not valid JSON")
146
+ end
147
+
148
+ def write_response!(socket, response, deadline)
149
+ payload = JSON.generate(response)
150
+ raise OrdinaryListenerError, "response exceeds the wire bound" if payload.bytesize > LIMIT
151
+ frame = "#{HANDSHAKE} #{payload.bytesize}\n#{payload}"
152
+ written = 0
153
+ while written < frame.bytesize
154
+ raise OrdinaryListenerError, "response write deadline expired" if Time.now.utc >= deadline
155
+ begin
156
+ written += socket.write_nonblock(frame.byteslice(written..))
157
+ rescue IO::WaitWritable
158
+ socket.wait_writable(remaining!(deadline)) or
159
+ raise OrdinaryListenerError, "response write deadline expired"
160
+ end
161
+ end
162
+ rescue JSON::GeneratorError
163
+ raise OrdinaryListenerError, "response is not serializable"
164
+ end
165
+
166
+ def refusal_envelope(code, message)
167
+ {"status" => "error", "error" => {
168
+ "schema" => "ace.lab.service.ordinary-merge-refusal/v1",
169
+ "code" => code,
170
+ "message" => message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512],
171
+ "request_id" => "unknown",
172
+ "input_digest" => nil
173
+ }}
174
+ end
175
+
176
+ def read_line!(socket, deadline)
177
+ line = +""
178
+ until line.end_with?("\n")
179
+ raise OrdinaryListenerError, "request header is unbounded" if line.bytesize > 128
180
+ line << read_exact!(socket, 1, deadline)
181
+ end
182
+ line
183
+ end
184
+
185
+ def read_exact!(socket, length, deadline)
186
+ buffer = +""
187
+ while buffer.bytesize < length
188
+ raise OrdinaryListenerError, "request read deadline expired" if Time.now.utc >= deadline
189
+ begin
190
+ chunk = socket.read_nonblock(length - buffer.bytesize)
191
+ raise OrdinaryListenerError, "peer closed the connection" if chunk.nil?
192
+ buffer << chunk
193
+ rescue IO::WaitReadable
194
+ socket.wait_readable(remaining!(deadline)) or
195
+ raise OrdinaryListenerError, "request read deadline expired"
196
+ end
197
+ end
198
+ buffer
199
+ end
200
+
201
+ def remaining!(deadline)
202
+ left = deadline - Time.now.utc
203
+ raise OrdinaryListenerError, "connection deadline expired" if left <= 0
204
+ left
205
+ end
206
+
207
+ def acquire_lock!
208
+ @lock = File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
209
+ stat = @lock.stat
210
+ unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
211
+ raise Ace::Lab::InvalidConfigurationError, "receiver listener lock differs"
212
+ end
213
+ unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
214
+ raise Ace::Lab::InvalidConfigurationError, "receiver listener already active"
215
+ end
216
+ end
217
+
218
+ def remove_stale_endpoint!
219
+ stat = File.lstat(@socket_path)
220
+ unless stat.socket? && stat.uid == Process.uid
221
+ raise Ace::Lab::InvalidConfigurationError, "receiver endpoint differs"
222
+ end
223
+ File.unlink(@socket_path)
224
+ rescue Errno::ENOENT
225
+ nil
226
+ end
227
+
228
+ def remove_owned_endpoint!
229
+ return unless @endpoint_identity
230
+ stat = File.stat(@socket_path)
231
+ File.unlink(@socket_path) if stat.socket? && stat.ino == @endpoint_identity.ino
232
+ rescue Errno::ENOENT
233
+ nil
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../errors"
5
+ require_relative "service_policy"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Project-scoped, explicitly filed receiver configuration (spec
11
+ # 8x3.t.xz9.4 execution clauses). The host owns the file, its
12
+ # placement, its credentials and its deployment; this loader only
13
+ # validates the exact closed shape and fails closed on anything else.
14
+ # It invents no credential and accepts no caller-selected endpoint:
15
+ # every value comes from one explicit deployment-owned YAML file.
16
+ class OrdinaryReceiverConfig
17
+ KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
18
+ repo_path policy_path execution_identity].freeze
19
+ IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
20
+ ID = ServicePolicy::ID
21
+
22
+ def self.load(path)
23
+ raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
24
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: false)
25
+ new(document, source: path)
26
+ rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
27
+ raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
28
+ end
29
+
30
+ attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
31
+ :principal_uid, :repo_path, :policy_path, :execution_identity, :source
32
+
33
+ def initialize(document, source: nil)
34
+ unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
35
+ (KEYS - document.keys).empty?
36
+ raise Ace::Lab::InvalidConfigurationError,
37
+ "receiver configuration must contain exactly: #{KEYS.join(', ')}"
38
+ end
39
+ @source = source
40
+ @project = string!(document.fetch("project"), "project")
41
+ @receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
42
+ @principal = string!(document.fetch("principal"), "principal")
43
+ @socket_path = absolute!(document.fetch("socket_path"), "socket_path")
44
+ @repo_path = absolute!(document.fetch("repo_path"), "repo_path")
45
+ @policy_path = absolute!(document.fetch("policy_path"), "policy_path")
46
+ @receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
47
+ @principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
48
+ identity = document.fetch("execution_identity")
49
+ unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
50
+ IDENTITY_KEYS.all? { |key| identity[key].is_a?(String) && !identity[key].empty? }
51
+ raise Ace::Lab::InvalidConfigurationError,
52
+ "receiver execution_identity is incomplete"
53
+ end
54
+ @execution_identity = IDENTITY_KEYS.to_h { |key| [key, identity.fetch(key)] }.freeze
55
+ unless @repo_path.start_with?("/") && File.directory?(@repo_path)
56
+ raise Ace::Lab::InvalidConfigurationError, "receiver repo_path is not a directory"
57
+ end
58
+ freeze
59
+ end
60
+
61
+ private
62
+
63
+ def string!(value, name)
64
+ unless value.is_a?(String) && !value.empty? && value.length <= 256 && value.match?(ID)
65
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} is invalid"
66
+ end
67
+ value
68
+ end
69
+
70
+ def absolute!(value, name)
71
+ unless value.is_a?(String) && value.start_with?("/") && value.length <= 1024
72
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be an absolute path"
73
+ end
74
+ value
75
+ end
76
+
77
+ def uid!(value, name)
78
+ unless value.is_a?(Integer) && value >= 0
79
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be a non-negative integer"
80
+ end
81
+ value
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Molecules
9
+ # Admission transport between the ordinary fixed merge producer and the
10
+ # installed ordinary receiver: one bounded JSON document each way over
11
+ # a configured unix stream socket with a fixed deadline. Authentication
12
+ # is mutual through SO_PEERCRED — the producer verifies the receiver's
13
+ # configured UID and transmits the verified local caller identity, so
14
+ # no caller-selected credential, token or endpoint exists. This is not
15
+ # an HTTP transport; a lost or unauthenticated response is a
16
+ # caller-side blocker (ordinary_merge_unavailable), never a refusal.
17
+ class OrdinarySocket
18
+ LIMIT = 16_384
19
+ HANDSHAKE = "ace-lab/ordinary-merge/v1"
20
+
21
+ # @param socket_path [String] installed receiver endpoint (configuration)
22
+ # @param receiver_uid [Integer] the only UID the receiver may run as
23
+ # @param deadline [Time] absolute deadline for connect/write/read
24
+ def initialize(socket_path:, receiver_uid:, deadline: Time.now.utc + 30)
25
+ @socket_path = socket_path
26
+ @receiver_uid = receiver_uid
27
+ @deadline = deadline
28
+ end
29
+
30
+ # Submit one bounded request document; returns the parsed response
31
+ # document. Any framing, deadline or identity failure raises
32
+ # OrdinaryUnavailable so the producer can classify the blocker.
33
+ def submit(document)
34
+ payload = JSON.generate(document)
35
+ raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
36
+ socket = Socket.new(:UNIX, :STREAM, 0)
37
+ begin
38
+ socket.connect(Addrinfo.unix(@socket_path))
39
+ verify_peer!(socket)
40
+ write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
41
+ response = read_response!(socket)
42
+ rescue OrdinaryUnavailable
43
+ raise
44
+ rescue SystemCallError, IOError => e
45
+ raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
46
+ ensure
47
+ begin
48
+ socket.close
49
+ rescue IOError
50
+ nil
51
+ end
52
+ end
53
+ response
54
+ end
55
+
56
+ class OrdinaryUnavailable < ::StandardError; end
57
+
58
+ private
59
+
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")
65
+ return if peer_uid == @receiver_uid
66
+ raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
67
+ end
68
+
69
+ def write_all!(socket, bytes)
70
+ written = 0
71
+ while written < bytes.bytesize
72
+ begin
73
+ raise OrdinaryUnavailable, "receiver write deadline expired" if expired?
74
+ written += socket.write_nonblock(bytes.byteslice(written..))
75
+ rescue IO::WaitWritable
76
+ socket.wait_writable(remaining!) or raise OrdinaryUnavailable, "receiver write deadline expired"
77
+ end
78
+ end
79
+ end
80
+
81
+ def read_response!(socket)
82
+ header = read_line!(socket)
83
+ match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
84
+ raise OrdinaryUnavailable, "receiver response header is invalid" unless match
85
+ length = Integer(match[1])
86
+ raise OrdinaryUnavailable, "receiver response exceeds the wire bound" if length > LIMIT
87
+ body = read_exact!(socket, length)
88
+ JSON.parse(body, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
89
+ rescue JSON::ParserError
90
+ raise OrdinaryUnavailable, "receiver response is not valid JSON"
91
+ end
92
+
93
+ def read_line!(socket)
94
+ line = +""
95
+ until line.end_with?("\n")
96
+ raise OrdinaryUnavailable, "receiver response header is unbounded" if line.bytesize > 128
97
+ line << read_exact!(socket, 1)
98
+ end
99
+ line
100
+ end
101
+
102
+ def read_exact!(socket, length)
103
+ buffer = +""
104
+ while buffer.bytesize < length
105
+ begin
106
+ raise OrdinaryUnavailable, "receiver read deadline expired" if expired?
107
+ chunk = socket.read_nonblock(length - buffer.bytesize)
108
+ raise OrdinaryUnavailable, "receiver closed the connection" if chunk.nil?
109
+ buffer << chunk
110
+ rescue IO::WaitReadable
111
+ socket.wait_readable(remaining!) or raise OrdinaryUnavailable, "receiver read deadline expired"
112
+ end
113
+ end
114
+ buffer
115
+ end
116
+
117
+ def expired?
118
+ Time.now.utc >= @deadline
119
+ end
120
+
121
+ def remaining!
122
+ left = @deadline - Time.now.utc
123
+ raise OrdinaryUnavailable, "receiver deadline expired" if left <= 0
124
+ left
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end