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
@@ -0,0 +1,736 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "open3"
5
+ require "fileutils"
6
+ require "tmpdir"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Idempotently ensures an ed25519 key pair exists for a manifest
12
+ # account, with .ssh 0700, the private key 0600, the public key 0644
13
+ # and ownership assigned to the account. Secret hygiene is an
14
+ # acceptance criterion: the public fingerprint is the ONLY key
15
+ # material fact that ever appears in a status, message or report —
16
+ # private key bytes are never read into Ruby, printed, or passed as a
17
+ # diagnostic argument; key paths inside a git worktree are refused
18
+ # outright.
19
+ class KeyProvisioner
20
+ SSH_DIR_MODE = 0o700
21
+ PRIVATE_MODE = 0o600
22
+ PUBLIC_MODE = 0o644
23
+ # Shared by the wet existing path and the dry-run prediction
24
+ # (same condition, same message — the two paths must never
25
+ # report different outcomes for the same pair).
26
+ NOT_USABLE_TEMPLATE = "key pair '%s' is not usable (unreadable or mismatched key files); " \
27
+ "resolve manually and rerun ace-lab prepare"
28
+
29
+ attr_reader :runner, :account_lookup, :dry_run
30
+
31
+ def initialize(runner: nil, account_lookup: nil, dry_run: false)
32
+ @runner = runner || method(:run_argv)
33
+ @account_lookup = account_lookup || method(:lookup_passwd)
34
+ @dry_run = dry_run
35
+ end
36
+
37
+ # @param key [Hash] normalized manifest key entry (path resolved)
38
+ # @param account [Hash] normalized manifest account entry
39
+ # @param machine [String] lab machine identity (key comment)
40
+ # @return [Hash] {account:, path:, status:, fingerprint:, message:}
41
+ def call(key:, account:, machine:)
42
+ provision(key, account, machine)
43
+ rescue Errno::EPERM, Errno::EACCES
44
+ {"account" => account["username"], "path" => key["path"], "status" => "failed",
45
+ "fingerprint" => nil,
46
+ "message" => "insufficient permissions while preparing '#{key["path"]}'; " \
47
+ "rerun ace-lab prepare with sudo or adjust ownership manually"}
48
+ # Filesystem ENOENT only: a missing ssh-keygen binary is already
49
+ # classified via the runner's "enoent" status (command_failure),
50
+ # so the remedy here is a convergence rerun, not installation.
51
+ rescue Errno::ENOENT
52
+ {"account" => account["username"], "path" => key["path"], "status" => "failed",
53
+ "fingerprint" => nil,
54
+ "message" => "key path '#{key["path"]}' disappeared while preparing; rerun ace-lab prepare"}
55
+ # Last-resort classifier mirroring DirectoryProvisioner: any
56
+ # other syscall failure on the provision path (e.g. ELOOP on
57
+ # the path-based directory chmod in a race) is a classified
58
+ # failed item, never a raw backtrace that aborts the report.
59
+ rescue SystemCallError => e
60
+ {"account" => account["username"], "path" => key["path"], "status" => "failed",
61
+ "fingerprint" => nil,
62
+ "message" => "key path '#{key["path"]}' could not be prepared (#{e.class.name}); " \
63
+ "rerun ace-lab prepare"}
64
+ end
65
+
66
+ # Public READ-ONLY pair probe (L8.5, task 8xg.t.sbz.7.4): the
67
+ # exact usability check the provisioner itself applies after
68
+ # convergence, exposed so the readiness verifier reuses the
69
+ # same logic instead of reimplementing it. Mutates nothing on
70
+ # the provisioned path (a mode-drifted private key is probed
71
+ # via a 0600 copy in a private tmpdir); secret hygiene holds —
72
+ # only the public fingerprint is ever returned.
73
+ #
74
+ # @param path [String] private key path
75
+ # @param public_path [String] public key path
76
+ # @return [String, nil] SHA256 fingerprint when the pair is
77
+ # usable, nil otherwise
78
+ def pair_fingerprint(path, public_path)
79
+ probe_pair_fingerprint(path, public_path)
80
+ end
81
+
82
+ private
83
+
84
+ def provision(key, account, machine)
85
+ username = account["username"]
86
+ path = key["path"]
87
+ base = {"account" => username, "path" => path}
88
+
89
+ if inside_git_worktree?(path)
90
+ return base.merge("status" => "failed", "fingerprint" => nil,
91
+ "message" => "key path '#{path}' is inside a git worktree; secrets must not live in Git")
92
+ end
93
+
94
+ stat = lstat(path)
95
+
96
+ if stat && !stat.file?
97
+ return base.merge("status" => "failed", "fingerprint" => nil,
98
+ "message" => "key path '#{path}' exists but is not a regular file; refusing to overwrite")
99
+ end
100
+
101
+ # Dry-run predicts from read-only filesystem probes and
102
+ # read-only ssh-keygen invocations (-lf/-y in
103
+ # classify_existing_pair), never mutating the pair or the
104
+ # account, and without consulting live passwd: the account
105
+ # phase may report would_create for an account that does not
106
+ # exist yet, and a nil lookup must not turn that prediction
107
+ # into a dependency failure.
108
+ return predict(base, path, stat) if dry_run
109
+
110
+ entry = account_lookup.call(username)
111
+ unless entry
112
+ return base.merge("status" => "failed", "fingerprint" => nil,
113
+ "message" => "account '#{username}' is not available on this machine; fix the account phase first")
114
+ end
115
+
116
+ return existing(base, path, stat, entry) if stat
117
+
118
+ generate(base, key, account, machine, path, entry)
119
+ end
120
+
121
+ # Prediction contract: dry-run must say what the wet run would do.
122
+ # A missing key pair (behind a safe .ssh) is would_create; an
123
+ # existing private key with a missing .pub is would_repair_public
124
+ # (the wet run regenerates it); mode drift on an owner-matching
125
+ # pair is would_fix_mode (the wet run enforces 0600/0644). The
126
+ # .ssh symlink/non-directory refusal runs unconditionally — the
127
+ # wet run refuses on both the create and the existing path, so a
128
+ # planted .ssh symlink must never predict exists/would_*.
129
+ #
130
+ # Invariant: EVERY wet-path refusal must have a predict
131
+ # counterpart here — the wet create/existing/predict tripling of
132
+ # guard logic means a refusal added to only one branch silently
133
+ # drifts dry-run fidelity (this happened with the pre-keygen
134
+ # .pub guard in round 5).
135
+ def predict(base, path, stat)
136
+ refusal = unsafe_ssh_dir_refusal(base, path)
137
+ return refusal if refusal
138
+
139
+ unless stat
140
+ # Parity with the wet generate path: a pre-existing
141
+ # foreign-owned .ssh would be refused, not predicted
142
+ # would_create (the account must be resolvable for the
143
+ # ownership check; a would-create account has no uid yet).
144
+ entry = account_lookup.call(base["account"])
145
+ if entry
146
+ ssh_stat = lstat(File.dirname(path))
147
+ if ssh_stat
148
+ refusal = ssh_dir_takeover_refusal(base, File.dirname(path), ssh_stat, entry)
149
+ return refusal if refusal
150
+ end
151
+ end
152
+
153
+ # Parity with the wet generate path (round 5, S1): a
154
+ # pre-planted <key>.pub symlink makes the wet run refuse
155
+ # before invoking ssh-keygen — the prediction must refuse
156
+ # too, never would_create.
157
+ public_path = "#{path}.pub"
158
+ pub_stat = lstat(public_path)
159
+ return non_regular_public_refusal(base, public_path) if pub_stat && !pub_stat.file?
160
+
161
+ return base.merge("status" => "would_create", "fingerprint" => nil, "message" => nil)
162
+ end
163
+
164
+ public_path = "#{path}.pub"
165
+ pub_stat = lstat(public_path)
166
+ if pub_stat && !pub_stat.file?
167
+ return non_regular_public_refusal(base, public_path)
168
+ end
169
+
170
+ unless pub_stat
171
+ return base.merge("status" => "would_repair_public", "fingerprint" => nil, "message" => nil)
172
+ end
173
+
174
+ entry = account_lookup.call(base["account"])
175
+ if entry
176
+ refusal = ownership_refusal(base, [stat, pub_stat], entry)
177
+ return refusal if refusal
178
+
179
+ ssh_stat = lstat(File.dirname(path))
180
+ if ssh_stat
181
+ refusal = ssh_dir_takeover_refusal(base, File.dirname(path), ssh_stat, entry)
182
+ return refusal if refusal
183
+ end
184
+ else
185
+ # Round 5 (C4): entry is nil only when the account phase
186
+ # would create the account, so the pair's ownership cannot
187
+ # be checked against a uid that does not exist yet. A pair
188
+ # owned by neither the installer nor the to-be-created
189
+ # account is conservatively predicted as the wet run's
190
+ # takeover refusal — pessimistic only in the pathological
191
+ # case where useradd reuses the foreign uid, never unsound.
192
+ foreign = [stat, pub_stat].compact.find { |candidate| candidate.uid != Process.euid }
193
+ if foreign
194
+ return base.merge("status" => "failed", "fingerprint" => nil,
195
+ "message" => "key path '#{path}' is owned by uid #{foreign.uid}, which is " \
196
+ "neither the installer nor the to-be-created account '#{base["account"]}'; " \
197
+ "the wet run would refuse takeover — resolve ownership manually")
198
+ end
199
+ end
200
+
201
+ # Shared pair classification BEFORE the drift branch: the
202
+ # wet run converges modes first and then fails verification
203
+ # on a garbage pair, so the prediction must say failed, not
204
+ # would_fix_mode — mode repair is only ever predicted for a
205
+ # pair that verifies as usable.
206
+ kind, fingerprint = classify_existing_pair(path, public_path)
207
+ case kind
208
+ when :repair_public
209
+ return base.merge("status" => "would_repair_public", "fingerprint" => nil, "message" => nil)
210
+ when :unusable
211
+ return base.merge("status" => "failed", "fingerprint" => nil,
212
+ "message" => format(NOT_USABLE_TEMPLATE, path))
213
+ end
214
+
215
+ if drift?(path, pub_stat, entry)
216
+ return base.merge("status" => "would_fix_mode", "fingerprint" => nil, "message" => nil)
217
+ end
218
+
219
+ base.merge("status" => "exists", "fingerprint" => fingerprint, "message" => nil)
220
+ end
221
+
222
+ # Shared classification of an existing key pair, used by BOTH
223
+ # the wet existing path and the dry-run prediction so the two
224
+ # can never drift apart: whatever ssh-keygen would verify after
225
+ # the wet convergence steps, the prediction judges read-only up
226
+ # front. @return [Array(Symbol, String|nil)] kind
227
+ # (:verified/:unusable/:repair_public) and the fingerprint
228
+ # (nil unless :verified).
229
+ def classify_existing_pair(path, public_path)
230
+ # TOCTOU safety net: callers lstat'd the .pub just before, so
231
+ # a nil here means the file vanished in between — deliberately
232
+ # kept (do not remove as "unreachable") so a race degrades to
233
+ # the same repair verdict the wet path would produce.
234
+ return [:repair_public, nil] if lstat(public_path).nil?
235
+
236
+ fingerprint = probe_pair_fingerprint(path, public_path)
237
+ fingerprint ? [:verified, fingerprint] : [:unusable, nil]
238
+ end
239
+
240
+ def drift?(path, pub_stat, entry)
241
+ private_stat = File.stat(path)
242
+ (private_stat.mode & 0o7777) != PRIVATE_MODE ||
243
+ (pub_stat.mode & 0o7777) != PUBLIC_MODE ||
244
+ (entry && (private_stat.uid != entry.uid || pub_stat.uid != entry.uid)) ||
245
+ (entry && ssh_dir_drift?(File.dirname(path), entry))
246
+ end
247
+
248
+ # The documented ".ssh is 0700" invariant is converged on the
249
+ # existing-key path too, not only at creation: mode drift (or
250
+ # an installer-owned .ssh that should belong to the account)
251
+ # is repaired exactly like key-file drift. Foreign ownership is
252
+ # NOT drift — it is refused (ssh_dir_takeover_refusal).
253
+ def ssh_dir_drift?(ssh_dir, entry)
254
+ stat = File.stat(ssh_dir)
255
+ (stat.mode & 0o7777) != SSH_DIR_MODE || stat.uid != entry.uid
256
+ rescue Errno::ENOENT, Errno::ENOTDIR
257
+ false
258
+ end
259
+
260
+ # Path-based directory convergence — the same accepted residual
261
+ # as the create path (Ruby stdlib has no fd-based directory
262
+ # chmod); a mid-flight SystemCallError is classified by the
263
+ # method-level rescue on call.
264
+ def converge_ssh_dir(ssh_dir, ssh_stat, entry)
265
+ return if ssh_stat.nil?
266
+ return if (ssh_stat.mode & 0o7777) == SSH_DIR_MODE && ssh_stat.uid == entry.uid
267
+
268
+ FileUtils.chmod(SSH_DIR_MODE, ssh_dir)
269
+ FileUtils.chown(entry.uid, entry.gid, ssh_dir)
270
+ end
271
+
272
+ # @return [Hash, nil] refusal when a pre-existing .ssh is owned
273
+ # by neither the declared account nor the installer
274
+ def ssh_dir_takeover_refusal(base, ssh_dir, stat, entry)
275
+ return nil if stat.uid == entry.uid || stat.uid == Process.euid
276
+
277
+ base.merge("status" => "failed", "fingerprint" => nil,
278
+ "message" => "key directory '#{ssh_dir}' is owned by uid #{stat.uid}, " \
279
+ "expected account '#{entry.name}'; refusing takeover — resolve ownership manually")
280
+ end
281
+
282
+ def existing(base, path, stat, entry)
283
+ # Same guard as generate: the wet existing path chmods/chowns
284
+ # both key files, and FileUtils.chmod/chown follow a planted
285
+ # intermediate .ssh symlink — lstat(path) only guards the
286
+ # final component (cross-account key theft otherwise).
287
+ refusal = unsafe_ssh_dir_refusal(base, path)
288
+ return refusal if refusal
289
+
290
+ ssh_dir = File.dirname(path)
291
+ ssh_stat = lstat(ssh_dir)
292
+ if ssh_stat
293
+ refusal = ssh_dir_takeover_refusal(base, ssh_dir, ssh_stat, entry)
294
+ return refusal if refusal
295
+ end
296
+
297
+ public_path = "#{path}.pub"
298
+ pub_stat = lstat(public_path)
299
+ if pub_stat && !pub_stat.file?
300
+ return non_regular_public_refusal(base, public_path)
301
+ end
302
+
303
+ # Ownership-drift policy mirrors DirectoryProvisioner: an
304
+ # existing pair owned by neither the declared account nor the
305
+ # installer is refused, never silently taken over.
306
+ refusal = ownership_refusal(base, [stat, pub_stat], entry)
307
+ return refusal if refusal
308
+
309
+ if pub_stat.nil?
310
+ regenerated = regenerate_public(base, path, public_path, entry)
311
+ return regenerated unless regenerated.nil?
312
+ end
313
+
314
+ refusal = enforce_modes_and_ownership(base, path, public_path, entry)
315
+ return refusal if refusal
316
+
317
+ # Converge the .ssh invariant on the existing path too (M1):
318
+ # 0700 + account ownership, refused never taken over when
319
+ # foreign-owned (checked above).
320
+ converge_ssh_dir(ssh_dir, ssh_stat, entry)
321
+
322
+ # The same shared classification the prediction runs —
323
+ # after the wet convergence steps a repair_public outcome is
324
+ # impossible (regenerate_public ran above), so only the
325
+ # usable/unusable verdict remains.
326
+ _kind, fingerprint = classify_existing_pair(path, public_path)
327
+ if fingerprint
328
+ base.merge("status" => "exists", "fingerprint" => fingerprint, "message" => nil)
329
+ else
330
+ base.merge("status" => "failed", "fingerprint" => nil,
331
+ "message" => format(NOT_USABLE_TEMPLATE, path))
332
+ end
333
+ end
334
+
335
+ def generate(base, key, account, machine, path, entry)
336
+ ssh_dir = File.dirname(path)
337
+ # An account-controlled home may carry a planted ~/.ssh symlink
338
+ # or non-directory; mkdir_p/chmod/chown would follow it in the
339
+ # privileged context (directory-ownership takeover of an
340
+ # arbitrary path), so refuse before any mutation.
341
+ refusal = unsafe_ssh_dir_refusal(base, path)
342
+ return refusal if refusal
343
+
344
+ refusal = ensure_ssh_dir(base, ssh_dir, entry)
345
+ return refusal if refusal
346
+
347
+ # Round 5 (S1): a pre-planted <key>.pub symlink must be
348
+ # refused BEFORE ssh-keygen runs — keygen creates the .pub by
349
+ # path and would write through the symlink (root-owned file
350
+ # creation at an account-chosen location). Mirrored in
351
+ # predict's missing-private branch.
352
+ public_path = "#{path}.pub"
353
+ pub_stat = lstat(public_path)
354
+ return non_regular_public_refusal(base, public_path) if pub_stat && !pub_stat.file?
355
+
356
+ # Round 5 (S2): only the mode is enforced before keygen; the
357
+ # .ssh ownership transfer is deliberately deferred until the
358
+ # pair is verified below. While ssh-keygen writes by path, a
359
+ # freshly created .ssh stays root-owned 0700 and unwritable
360
+ # by the account, so no symlink can be planted inside it
361
+ # during the keygen window. Remaining residual (documented
362
+ # in usage.md): on a home the account already owns, the
363
+ # account can rename the root-owned .ssh mid-flight and
364
+ # substitute its own (caught by the post-keygen lstat only
365
+ # after the write).
366
+ FileUtils.chmod(SSH_DIR_MODE, ssh_dir)
367
+
368
+ comment = "#{account['username']}@#{machine}"
369
+ result = runner.call(
370
+ ["ssh-keygen", "-q", "-t", key["type"], "-N", "", "-C", comment, "-f", path]
371
+ )
372
+ unless result["status"] == "ok"
373
+ return base.merge("status" => "failed", "fingerprint" => nil,
374
+ "message" => command_failure("ssh-keygen", result))
375
+ end
376
+
377
+ # Post-keygen lstat: a keygen writing through a symlink
378
+ # planted after the guards above must be detected and refused
379
+ # before any ownership transfer touches the path.
380
+ post = lstat(path)
381
+ post_pub = lstat(public_path)
382
+ unless post && post.file? && post_pub && post_pub.file?
383
+ return base.merge("status" => "failed", "fingerprint" => nil,
384
+ "message" => "key '#{path}' was not written as a regular file; " \
385
+ "refusing — rerun ace-lab prepare")
386
+ end
387
+
388
+ refusal = enforce_modes_and_ownership(base, path, public_path, entry)
389
+ return refusal if refusal
390
+
391
+ # Deferred .ssh ownership transfer (S2): only now, with the
392
+ # pair verified as regular files and the key files already
393
+ # carrying account ownership, does the directory pass to the
394
+ # account.
395
+ FileUtils.chown(entry.uid, entry.gid, ssh_dir)
396
+
397
+ verified_result(base, "created", path, public_path,
398
+ "key pair '#{path}' was created but could not be verified as usable; " \
399
+ "rerun ace-lab prepare")
400
+ end
401
+
402
+ # A key pair is ready only when it is USABLE: the public file
403
+ # yields a fingerprint AND the key derived from the private
404
+ # file matches it. `ssh-keygen -y -f <private>` regenerates the
405
+ # public half from the private key (garbage files fail here),
406
+ # and its fingerprint is compared against the `.pub` file's —
407
+ # note `-lf <private>` cannot be used for that comparison:
408
+ # ssh-keygen resolves the ADJACENT `.pub` instead of deriving
409
+ # from the private key, so a swapped `.pub` would compare with
410
+ # itself. Only the fingerprint — public key material — ever
411
+ # reaches the report; private bytes stay inside the ssh-keygen
412
+ # subprocess, and the derived public key lives in a private
413
+ # tmpdir for the duration of one read-only comparison. A pair
414
+ # that cannot be verified is a failed item, never exists or
415
+ # created with fingerprint:nil.
416
+ def probe_pair_fingerprint(path, public_path)
417
+ public_fingerprint = read_fingerprint(public_path)
418
+ return nil unless public_fingerprint
419
+
420
+ usable = with_derivable_private(path) do |private_path|
421
+ derived_matches_public?(private_path, public_fingerprint)
422
+ end
423
+ usable ? public_fingerprint : nil
424
+ end
425
+
426
+ # Whether the public key ssh-keygen derives from the private
427
+ # half matches the `.pub` file's fingerprint (see
428
+ # pair_fingerprint for why the derivation cannot be shortcut).
429
+ # @return [Boolean]
430
+ def derived_matches_public?(private_path, public_fingerprint)
431
+ derived = runner.call(["ssh-keygen", "-y", "-f", private_path])
432
+ return false unless derived["status"] == "ok"
433
+
434
+ Dir.mktmpdir("ace-lab-key") do |tmp|
435
+ derived_path = File.join(tmp, "derived.pub")
436
+ File.write(derived_path, derived["out"])
437
+ read_fingerprint(derived_path) == public_fingerprint
438
+ end
439
+ end
440
+
441
+ # ssh-keygen refuses to load a private key whose permission
442
+ # bits are too open ("bad permissions"), even for a read-only
443
+ # derivation. The shared pair classification must judge a
444
+ # mode-drifted pair BEFORE the wet chmod converges it, so the
445
+ # derivation runs against a 0600 copy in a private tmpdir when
446
+ # the mode has drifted — read-only with respect to the
447
+ # provisioned path, and the copy never leaves the tmpdir (a
448
+ # byte-stream via FileUtils.cp, never printed or rendered).
449
+ def with_derivable_private(path)
450
+ if (File.stat(path).mode & 0o7777) == PRIVATE_MODE
451
+ return yield(path)
452
+ end
453
+
454
+ Dir.mktmpdir("ace-lab-key") do |tmp|
455
+ copy = File.join(tmp, "private")
456
+ FileUtils.cp(path, copy)
457
+ File.chmod(PRIVATE_MODE, copy)
458
+ yield(copy)
459
+ end
460
+ end
461
+
462
+ def verified_result(base, success_status, path, public_path, failure_message)
463
+ fingerprint = probe_pair_fingerprint(path, public_path)
464
+ if fingerprint
465
+ base.merge("status" => success_status, "fingerprint" => fingerprint, "message" => nil)
466
+ else
467
+ base.merge("status" => "failed", "fingerprint" => nil, "message" => failure_message)
468
+ end
469
+ end
470
+
471
+ # Regenerate a missing .pub from the existing private key. The
472
+ # derived public key is written straight to the .pub file — never
473
+ # printed, never placed in a message.
474
+ def regenerate_public(base, path, public_path, entry)
475
+ result = runner.call(["ssh-keygen", "-y", "-f", path])
476
+ if result["status"] != "ok"
477
+ return base.merge("status" => "failed", "fingerprint" => nil,
478
+ "message" => "public key '#{public_path}' is missing and could not be regenerated " \
479
+ "(#{command_failure('ssh-keygen', result)})")
480
+ end
481
+
482
+ # O_NOFOLLOW: a dangling .pub symlink planted by the account
483
+ # must not turn this into an arbitrary privileged write; ELOOP
484
+ # (symlink raced in after the lstat guard) is refused, not
485
+ # raised. chmod/chown are fd-based on the already-verified
486
+ # open handle, so no path-based re-resolution can race the
487
+ # open.
488
+ begin
489
+ File.open(public_path, File::WRONLY | File::CREAT | File::TRUNC | File::NOFOLLOW) do |file|
490
+ file.write(result["out"])
491
+ file.chmod(PUBLIC_MODE)
492
+ file.chown(entry.uid, entry.gid)
493
+ end
494
+ rescue Errno::ELOOP
495
+ return non_regular_public_refusal(base, public_path)
496
+ end
497
+ nil
498
+ end
499
+
500
+ # Close the create-time TOCTOU window as far as stdlib allows:
501
+ # the leaf is created with Dir.mkdir, whose EEXIST on a planted
502
+ # symlink never follows it, and the created leaf is re-lstat'd
503
+ # before the caller's chmod/chown — a leaf swapped mid-flight
504
+ # for a symlink or a directory this process did not create is
505
+ # refused instead of mutated. The window between this re-lstat
506
+ # and the path-based chmod/chown that follow is a documented
507
+ # accepted residual (usage.md).
508
+ def ensure_ssh_dir(base, ssh_dir, entry)
509
+ # Intermediates (e.g. the home directory) are created per the
510
+ # documented policy — mkdir_p follows existing components; the
511
+ # leaf .ssh itself is the account-controlled component that
512
+ # must not be created or accepted through a symlink.
513
+ # mkdir_p re-raises EEXIST when an existing component is not
514
+ # a directory and ENOTDIR for components under a file — both
515
+ # classified here (a leftover regular file at the manifest
516
+ # home must fail the item, not abort the report).
517
+ begin
518
+ FileUtils.mkdir_p(File.dirname(ssh_dir))
519
+ rescue Errno::EEXIST, Errno::ENOTDIR
520
+ return base.merge("status" => "failed", "fingerprint" => nil,
521
+ "message" => "key directory '#{ssh_dir}' could not be created; " \
522
+ "check the parent path and rerun ace-lab prepare")
523
+ end
524
+ created = false
525
+ begin
526
+ Dir.mkdir(ssh_dir)
527
+ created = true
528
+ rescue Errno::EEXIST
529
+ stat = lstat(ssh_dir)
530
+ unless stat && stat.directory?
531
+ return base.merge("status" => "failed", "fingerprint" => nil,
532
+ "message" => "key directory '#{ssh_dir}' was replaced while preparing; " \
533
+ "refusing — rerun ace-lab prepare")
534
+ end
535
+ # A pre-existing .ssh owned by neither the declared account
536
+ # nor the installer is foreign principal material: chmod
537
+ # 0700 + chown would be a takeover, so refuse like key
538
+ # files (the documented blanket policy).
539
+ refusal = ssh_dir_takeover_refusal(base, ssh_dir, stat, entry)
540
+ return refusal if refusal
541
+ rescue Errno::ENOENT, Errno::ENOTDIR
542
+ return base.merge("status" => "failed", "fingerprint" => nil,
543
+ "message" => "key directory '#{ssh_dir}' could not be created; rerun ace-lab prepare")
544
+ end
545
+
546
+ if created
547
+ stat = lstat(ssh_dir)
548
+ unless stat && stat.directory? && stat.uid == Process.euid
549
+ return base.merge("status" => "failed", "fingerprint" => nil,
550
+ "message" => "key directory '#{ssh_dir}' changed while preparing; " \
551
+ "refusing — rerun ace-lab prepare")
552
+ end
553
+ end
554
+ nil
555
+ end
556
+
557
+ # fd-based, the same pattern as regenerate_public: chmod/chown
558
+ # happen on an O_NOFOLLOW-opened handle, so no path
559
+ # re-resolution can race the open with an account-planted
560
+ # symlink (arbitrary chown-to-account). ELOOP — a symlink
561
+ # swapped in after the lstat guards — is refused, not raised.
562
+ # @return [Hash, nil] a refusal report when the file was
563
+ # replaced by a symlink mid-flight
564
+ def enforce_modes_and_ownership(base, path, public_path, entry)
565
+ begin
566
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
567
+ file.chmod(PRIVATE_MODE)
568
+ file.chown(entry.uid, entry.gid)
569
+ end
570
+ rescue Errno::ELOOP
571
+ return base.merge("status" => "failed", "fingerprint" => nil,
572
+ "message" => "key '#{path}' was replaced by a symbolic link while preparing; " \
573
+ "refusing — rerun ace-lab prepare")
574
+ end
575
+
576
+ begin
577
+ File.open(public_path, File::RDONLY | File::NOFOLLOW) do |file|
578
+ file.chmod(PUBLIC_MODE)
579
+ file.chown(entry.uid, entry.gid)
580
+ end
581
+ rescue Errno::ELOOP
582
+ return non_regular_public_refusal(base, public_path)
583
+ rescue Errno::ENOENT
584
+ # Vanished between the lstat guard and here; the next run
585
+ # regenerates it — not worth failing the whole item.
586
+ nil
587
+ end
588
+ nil
589
+ end
590
+
591
+ # A pre-existing key file owned by neither the declared account
592
+ # nor the installer is foreign principal material at a
593
+ # deployment-chosen path (e.g. a reused home directory): refuse
594
+ # the takeover, mirroring the directory provisioner's policy.
595
+ # @param stats [Array<File::Stat, nil>] lstats of the pair
596
+ # @return [Hash, nil]
597
+ def ownership_refusal(base, stats, entry)
598
+ foreign = stats.compact.find { |stat| stat.uid != entry.uid && stat.uid != Process.euid }
599
+ return nil unless foreign
600
+
601
+ base.merge("status" => "failed", "fingerprint" => nil,
602
+ "message" => "key path '#{base["path"]}' is owned by uid #{foreign.uid}, " \
603
+ "expected account '#{entry.name}'; refusing takeover — resolve ownership manually")
604
+ end
605
+
606
+ def non_regular_public_refusal(base, public_path)
607
+ base.merge("status" => "failed", "fingerprint" => nil,
608
+ "message" => "public key '#{public_path}' exists but is not a regular file; refusing")
609
+ end
610
+
611
+ # All filesystem probes below are lstat-based: File.exist?
612
+ # follows symlinks and must not gate privileged chmod/chown on
613
+ # account-controlled paths.
614
+ def unsafe_ssh_dir_refusal(base, path)
615
+ ssh_dir = File.dirname(path)
616
+ stat = lstat(ssh_dir)
617
+ return nil unless stat
618
+
619
+ if stat.symlink?
620
+ base.merge("status" => "failed", "fingerprint" => nil,
621
+ "message" => "key directory '#{ssh_dir}' is a symbolic link; refusing")
622
+ elsif !stat.directory?
623
+ base.merge("status" => "failed", "fingerprint" => nil,
624
+ "message" => "key directory '#{ssh_dir}' exists but is not a directory; refusing")
625
+ end
626
+ end
627
+
628
+ def lstat(path)
629
+ File.lstat(path)
630
+ rescue Errno::ENOENT, Errno::ENOTDIR
631
+ nil
632
+ end
633
+
634
+ # @return [String, nil] SHA256 fingerprint from `ssh-keygen -lf`;
635
+ # read-only, safe in dry-run
636
+ def read_fingerprint(public_path)
637
+ result = runner.call(["ssh-keygen", "-lf", public_path])
638
+ return nil unless result["status"] == "ok"
639
+
640
+ match = result["out"].match(/(SHA256:[A-Za-z0-9+\/=]+)/)
641
+ match && match[1]
642
+ end
643
+
644
+ # Classified one-line command failure: status plus the first stderr
645
+ # line only — never command output bodies (secret hygiene).
646
+ def command_failure(command, result)
647
+ if result["status"] == "enoent"
648
+ return "#{command} not found in PATH; install it and rerun ace-lab prepare"
649
+ end
650
+ if result["status"] == "exec_failed"
651
+ return "#{command} could not be executed (#{result['err']}); rerun ace-lab prepare"
652
+ end
653
+
654
+ message = "#{command} failed (#{exit_detail(result['status'])})"
655
+ line = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
656
+ line ? "#{message}: #{line}" : message
657
+ end
658
+
659
+ # Round 5 (C2): a signal-killed child has no exitstatus (nil) —
660
+ # render the terminating signal instead of a malformed
661
+ # "exit " interpolation.
662
+ def exit_detail(status)
663
+ status.is_a?(Integer) ? "exit #{status}" : status.to_s
664
+ end
665
+
666
+ # Walk up from the key path's PHYSICAL location looking for a
667
+ # .git entry: key material must never live inside a git worktree
668
+ # (acceptance criterion). Lexical ancestors are not enough — an
669
+ # ancestor symlink (alias -> /repo/nested) would land the real
670
+ # key inside the repository while this walk sees only the alias
671
+ # spelling. Resolution happens before any generate/mutate step,
672
+ # like the lstat guards above. An unresolvable-for-permission
673
+ # path (nil physical_path) is judged as INSIDE — fail-closed —
674
+ # because an ancestor denying search could hide exactly the
675
+ # symlink the resolution exists to catch.
676
+ def inside_git_worktree?(path)
677
+ physical = physical_path(File.expand_path(path))
678
+ return true if physical.nil?
679
+
680
+ current = File.dirname(physical)
681
+ while (parent = File.dirname(current))
682
+ return true if File.exist?(File.join(current, ".git"))
683
+ break if parent == current
684
+
685
+ current = parent
686
+ end
687
+ false
688
+ end
689
+
690
+ # Resolve where `path` physically lives: realpath the deepest
691
+ # existing ancestor (following symlinks — the same
692
+ # resolve-before-mutate discipline as the lstat guards), then
693
+ # rebuild the not-yet-existing tail on top, so a missing `.ssh`
694
+ # behind an existing alias is judged by the alias target too.
695
+ # Returns nil when an ancestor resolves with EACCES (account
696
+ # denies search to the unprivileged installer): the tail beyond
697
+ # it cannot be verified, so callers must fail closed instead of
698
+ # falling back to the lexical spelling.
699
+ def physical_path(path)
700
+ current = path
701
+ tail = []
702
+ until current == "/"
703
+ resolved = begin
704
+ File.realpath(current)
705
+ rescue Errno::ENOENT, Errno::ENOTDIR
706
+ nil
707
+ rescue Errno::EACCES
708
+ return nil
709
+ end
710
+ return File.join(resolved, *tail) unless resolved.nil?
711
+
712
+ tail.unshift(File.basename(current))
713
+ current = File.dirname(current)
714
+ end
715
+ File.join("/", *tail)
716
+ end
717
+
718
+ def run_argv(argv)
719
+ out, err, status = Open3.capture3(*argv)
720
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
721
+ "out" => out, "err" => err}
722
+ rescue Errno::ENOENT
723
+ {"status" => "enoent", "out" => "", "err" => ""}
724
+ rescue SystemCallError => e
725
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
726
+ end
727
+
728
+ def lookup_passwd(username)
729
+ Etc.getpwnam(username)
730
+ rescue ArgumentError
731
+ nil
732
+ end
733
+ end
734
+ end
735
+ end
736
+ end