ace-lab 0.9.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -104
- data/README.md +66 -100
- data/docs/usage.md +529 -592
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -23
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -108
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -92
- data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
- data/lib/ace/lab/cli/commands/run.rb +0 -65
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
- data/lib/ace/lab/molecules/command_policy.rb +0 -158
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/command_executor.rb +0 -220
- data/lib/ace/lab/organisms/command_run_client.rb +0 -131
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- 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
|