ace-lab 0.8.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -13
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -102
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -1,232 +0,0 @@
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
@@ -1,94 +0,0 @@
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
@@ -1,126 +0,0 @@
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 OS peer credentials — 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 = nil
37
- begin
38
- socket = UNIXSocket.new(@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, SocketError, NotImplementedError => 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
- peer_uid, = socket.getpeereid
62
- return if peer_uid == @receiver_uid
63
- raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
64
- end
65
-
66
- def write_all!(socket, bytes)
67
- written = 0
68
- while written < bytes.bytesize
69
- begin
70
- raise OrdinaryUnavailable, "receiver write deadline expired" if expired?
71
- written += socket.write_nonblock(bytes.byteslice(written..))
72
- rescue IO::WaitWritable
73
- socket.wait_writable(remaining!) or raise OrdinaryUnavailable, "receiver write deadline expired"
74
- end
75
- end
76
- end
77
-
78
- def read_response!(socket)
79
- header = read_line!(socket)
80
- match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
81
- raise OrdinaryUnavailable, "receiver response header is invalid" unless match
82
- length = Integer(match[1])
83
- raise OrdinaryUnavailable, "receiver response exceeds the wire bound" if length > LIMIT
84
- body = read_exact!(socket, length)
85
- JSON.parse(body, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
86
- rescue JSON::ParserError
87
- raise OrdinaryUnavailable, "receiver response is not valid JSON"
88
- end
89
-
90
- def read_line!(socket)
91
- line = +""
92
- until line.end_with?("\n")
93
- raise OrdinaryUnavailable, "receiver response header is unbounded" if line.bytesize > 128
94
- line << read_exact!(socket, 1)
95
- end
96
- line
97
- end
98
-
99
- def read_exact!(socket, length)
100
- buffer = +""
101
- while buffer.bytesize < length
102
- begin
103
- raise OrdinaryUnavailable, "receiver read deadline expired" if expired?
104
- chunk = socket.read_nonblock(length - buffer.bytesize)
105
- raise OrdinaryUnavailable, "receiver closed the connection" if chunk.nil?
106
- buffer << chunk
107
- rescue IO::WaitReadable
108
- socket.wait_readable(remaining!) or raise OrdinaryUnavailable, "receiver read deadline expired"
109
- end
110
- end
111
- buffer
112
- end
113
-
114
- def expired?
115
- Time.now.utc >= @deadline
116
- end
117
-
118
- def remaining!
119
- left = @deadline - Time.now.utc
120
- raise OrdinaryUnavailable, "receiver deadline expired" if left <= 0
121
- left
122
- end
123
- end
124
- end
125
- end
126
- end
@@ -1,216 +0,0 @@
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