yamine 0.19.0 → 0.21.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.
data/lib/yamine/doctor.rb CHANGED
@@ -34,6 +34,8 @@ module Yamine
34
34
  checks << check_dns(store)
35
35
  checks << check_ca
36
36
  checks << check_ca_bundle
37
+ checks << check_serving_ca(port, tls: tls)
38
+ checks << check_service
37
39
  checks
38
40
  end
39
41
 
@@ -224,6 +226,38 @@ module Yamine
224
226
  Check.new(name: "bundle", ok: false, message: "could not build #{Certs.bundle_path(dir)}: #{e.message}")
225
227
  end
226
228
 
229
+ # The certificate the proxy ACTUALLY serves, verified against the CA
230
+ # now on disk. The CA can be regenerated under a live proxy
231
+ # (`yamine clean`/`trust`/`setup` rewrite the pair); without this
232
+ # check doctor reported "[ok] proxy: listening" and "[ok] ca: CA
233
+ # trusted" while every browser got ERR_CERT_AUTHORITY_INVALID,
234
+ # because nothing compared the served leaf to the current CA.
235
+ def check_serving_ca(port, tls:)
236
+ return Check.new(name: "serving ca", ok: true, message: "TLS off — no serving certificate to check") unless tls
237
+ return Check.new(name: "serving ca", ok: true, message: "no proxy serving — skipped") if port.nil?
238
+
239
+ dir = Certs.state_dir
240
+ ca_path = Certs.ca_paths(dir)[:cert]
241
+ return Check.new(name: "serving ca", ok: true, message: "no CA on disk — skipped") unless File.file?(ca_path)
242
+
243
+ leaf = Trust.proxy_peer_cert(port)
244
+ return Check.new(name: "serving ca", ok: true, warn: true,
245
+ message: "could not read the proxy's serving certificate — skipped") if leaf.nil?
246
+
247
+ ca = OpenSSL::X509::Certificate.new(File.read(ca_path))
248
+ if Trust.signed_by?(leaf, ca)
249
+ Check.new(name: "serving ca", ok: true, message: "serving certificate matches the CA on disk")
250
+ else
251
+ Check.new(name: "serving ca", ok: false,
252
+ message: "proxy serves a certificate signed by a superseded CA — " \
253
+ "restart it: sudo launchctl kickstart -k system/dev.yamine " \
254
+ "(or sudo yamine service install to re-register the service after a gem upgrade)")
255
+ end
256
+ rescue StandardError => e
257
+ Check.new(name: "serving ca", ok: true, warn: true,
258
+ message: "could not check the serving certificate (#{e.message}) — skipped")
259
+ end
260
+
227
261
  # How many trusted certificates carry one of our CA names without
228
262
  # being the CA currently on disk. Best-effort: 0 when the keychain
229
263
  # cannot be read (doctor must never fail on a read-only probe).
@@ -244,6 +278,71 @@ module Yamine
244
278
  0
245
279
  end
246
280
 
281
+ # The privileged service: what runs as root on port 443.
282
+ #
283
+ # Three states. A legacy unit — payload inside a user home or a
284
+ # version-stamped gem directory — keeps working, so it warns (never
285
+ # fails) with the exact migration. A staged unit whose tree fails
286
+ # ownership verification FAILS: user-writable code running as root
287
+ # is the hole this check exists to close. Version skew between the
288
+ # staged payload and this CLI warns. The interpreter is always
289
+ # reported truthfully when a service exists: there is no root-owned
290
+ # Ruby new enough for the gem, so the daemon runs a user-writable
291
+ # interpreter and that residual risk stays visible.
292
+ def check_service
293
+ unless PrivilegedPayload.service_unit_present?
294
+ return Check.new(name: "service", ok: true,
295
+ message: "no privileged service installed — skipped")
296
+ end
297
+
298
+ ref = PrivilegedPayload.installed_payload_ref
299
+ if ref.nil?
300
+ return Check.new(name: "service", ok: true, warn: true,
301
+ message: "a privileged unit is installed but its payload path is unreadable" \
302
+ " — re-install it: sudo yamine service install")
303
+ end
304
+
305
+ if PrivilegedPayload.legacy_ref?(ref)
306
+ return Check.new(name: "service", ok: true, warn: true,
307
+ message: "the privileged service runs a user-writable legacy payload (#{ref}) — " \
308
+ "migrate it: sudo yamine service install")
309
+ end
310
+
311
+ problems = []
312
+ warnings = []
313
+ begin
314
+ PrivilegedPayload.verify!
315
+ rescue Error => e
316
+ problems << "#{e.message} — re-install it: sudo yamine service install"
317
+ end
318
+
319
+ staged = PrivilegedPayload.staged_version
320
+ if staged.nil?
321
+ warnings << "the service points at the staged payload but no staged version is recorded" \
322
+ " — re-install it: sudo yamine service install"
323
+ elsif staged != Yamine::VERSION
324
+ warnings << "the staged payload is v#{staged}, this CLI is v#{Yamine::VERSION}" \
325
+ " — re-install it: sudo yamine service install"
326
+ end
327
+
328
+ ruby = PrivilegedPayload.ruby_info
329
+ if ruby[:writable]
330
+ warnings << "the service interpreter #{ruby[:path]} is user-writable — " \
331
+ "anything that can write it changes what runs as root; only the payload above is root-owned " \
332
+ "(no root-owned Ruby >= 3.2 exists on this machine, so this residual risk stays until one does)"
333
+ end
334
+
335
+ if problems.empty? && warnings.empty?
336
+ Check.new(name: "service", ok: true,
337
+ message: "staged payload v#{staged} (#{PrivilegedPayload.bin_path})")
338
+ elsif problems.empty?
339
+ Check.new(name: "service", ok: true, warn: true, message: warnings.join("; "))
340
+ else
341
+ Check.new(name: "service", ok: false,
342
+ message: (problems + warnings).join("; "))
343
+ end
344
+ end
345
+
247
346
  def print(checks, out: $stdout, json: false)
248
347
  if json
249
348
  require "json"
data/lib/yamine/errors.rb CHANGED
@@ -33,5 +33,9 @@ module Yamine
33
33
  end
34
34
  end
35
35
  class ProxyNotRunningError < Error; end
36
+ # spawn_daemon raises this instead of spawning when our proxy already
37
+ # answers on the port: the readiness probe would otherwise pass on the
38
+ # live proxy's behalf and a dead pid would be recorded over real state.
39
+ class ProxyAlreadyRunningError < Error; end
36
40
  class CertError < Error; end
37
41
  end
data/lib/yamine/hosts.rb CHANGED
@@ -46,14 +46,14 @@ module Yamine
46
46
  # Bring the managed block to exactly these hostnames. Idempotent: when
47
47
  # the block already matches, this is a no-op returning true.
48
48
  #
49
- # The sync short-circuit is load-bearing, not an optimization. /etc/hosts is
50
- # root-owned on a normal machine, so a rewrite fails without sudo — and
51
- # `setup` and every boot's workstation check run this. Without the
52
- # guard, a machine whose hosts file was already correct got a
53
- # "could not write /etc/hosts (try sudo yamine hosts sync)" warning on
54
- # every run, sending people to an elevated write for a file that
55
- # needed nothing.
56
- def sync(hostnames, path = PATH)
49
+ # Every name is validated BEFORE anything is read or written — even
50
+ # on the already-synced path. routes.json is user-writable and this
51
+ # runs as root, so a crafted name (an embedded newline, a real
52
+ # vendor domain) would otherwise turn the sync into a domain-hijack
53
+ # primitive. Raises Yamine::Error instead of writing.
54
+ def sync(hostnames, path = PATH, tlds: nil)
55
+ tlds ||= default_scope_tlds
56
+ validate!(hostnames, tlds: tlds)
57
57
  return true if synced?(hostnames, path)
58
58
 
59
59
  content = read(path)
@@ -77,6 +77,74 @@ module Yamine
77
77
  read(path).include?(managed_block(hostnames))
78
78
  end
79
79
 
80
+ # Well-formed hostname only: labels of letters/digits/hyphens that
81
+ # neither start nor end with a hyphen, dots between them, 253 chars
82
+ # total. Anything else — whitespace, newlines, empty labels — is
83
+ # refused before it can reach the root-owned file.
84
+ def valid_hostname?(name)
85
+ host = name.to_s.downcase.sub(/\.\z/, "")
86
+ return false if host.empty? || host.length > 253
87
+
88
+ labels = host.split(".")
89
+ return false if labels.empty?
90
+
91
+ labels.all? do |label|
92
+ !label.empty? && label.length <= 63 &&
93
+ label.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
94
+ end
95
+ end
96
+
97
+ # In scope when the name IS an allowed dev TLD or sits under one.
98
+ # Suffix matching only: "evilgithub.com" is not "github.com", and
99
+ # "evil.github.com" is not "localhost".
100
+ def in_scope?(hostname, tlds)
101
+ host = hostname.to_s.downcase.sub(/\.\z/, "")
102
+ Array(tlds).map { |t| t.to_s.downcase }.any? do |tld|
103
+ !tld.empty? && (host == tld || host.end_with?(".#{tld}"))
104
+ end
105
+ end
106
+
107
+ # Fail closed on the first out-of-scope name: a partial managed
108
+ # block that silently drops a route would send people debugging
109
+ # DNS for a policy decision.
110
+ def validate!(hostnames, tlds:)
111
+ invalid = Array(hostnames).reject { |h| valid_hostname?(h) }
112
+ unless invalid.empty?
113
+ raise Error, "refusing hosts sync: invalid hostname(s): #{invalid.join(", ")}"
114
+ end
115
+
116
+ outside = Array(hostnames).reject { |h| in_scope?(h, tlds) }
117
+ unless outside.empty?
118
+ raise Error, "refusing hosts sync: #{outside.join(", ")} " \
119
+ "is outside the allowed dev TLDs (#{Array(tlds).join(", ")}). " \
120
+ "Keep names under .localhost, or have a human allow the parent domain."
121
+ end
122
+ true
123
+ end
124
+
125
+ # Which TLDs a sync may write under. *.localhost is always allowed
126
+ # (RFC 6761 reserves .localhost to loopback, so it can never hijack
127
+ # a real domain). Everything else comes from the root-owned
128
+ # allowlist when one is staged; before the first elevated install
129
+ # it falls back to the TLDs the proxy actually serves.
130
+ def default_scope_tlds(state_dir = Certs.state_dir)
131
+ tlds = ["localhost"]
132
+ staged = PrivilegedPayload.allowlist_file
133
+ if File.file?(staged)
134
+ tlds |= read_tld_list(staged)
135
+ else
136
+ tlds |= read_tld_list(File.join(state_dir, "proxy.tlds"))
137
+ end
138
+ tlds
139
+ end
140
+
141
+ def read_tld_list(path)
142
+ File.readlines(path).map { |l| l.strip.downcase }.reject(&:empty?)
143
+ .select { |t| Sanitize.valid_tld?(t) }.uniq
144
+ rescue SystemCallError
145
+ []
146
+ end
147
+
80
148
  def clean(path = PATH)
81
149
  content = read(path)
82
150
  updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, "")
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Yamine
6
+ # The root-owned, version-independent payload the privileged service
7
+ # runs.
8
+ #
9
+ # The root daemon used to execute the gem's bin+lib straight out of
10
+ # the user's home (a version-stamped, user-writable gem directory),
11
+ # so anything that could write those paths — a gem upgrade, a bundle
12
+ # install, an agent editing the gem — ran as root at every boot.
13
+ # Instead the elevated install stages bin+lib once into a root-owned
14
+ # directory at a path that never changes across versions, and the
15
+ # launchd/systemd unit plus the passwordless grant pin only that
16
+ # path. User-space writes can no longer change what runs as root;
17
+ # introducing new root-executed code stays a human-authorized
18
+ # interactive-sudo step (`sudo yamine service install`).
19
+ module PrivilegedPayload
20
+ # Conventional root-owned homes, one leaf name ("yamine") on both
21
+ # platforms: /Library/* is where macOS keeps machine-wide app data,
22
+ # /usr/local/share is the FHS equivalent on Linux.
23
+ MACOS_ROOT = "/Library/Application Support/yamine"
24
+ LINUX_ROOT = "/usr/local/share/yamine"
25
+ KNOWN_PARENTS = ["/Library/Application Support", "/usr/local/share", "/opt"].freeze
26
+
27
+ # Test/dev override for the root (the suite redirects it at a
28
+ # tmpdir). Never survives the sudo boundary without SETENV, which
29
+ # the grant does not set — so the passwordless rules always pin
30
+ # the real root, never this.
31
+ ROOT_ENV_VAR = "YAMINE_PRIVILEGED_ROOT"
32
+
33
+ VERSIONS_DIRNAME = "versions"
34
+ CURRENT_LINKNAME = "current"
35
+ VERSION_FILENAME = "VERSION"
36
+ ALLOWLIST_FILENAME = "allowed-tlds"
37
+
38
+ LAUNCHD_LABEL = "dev.yamine"
39
+ PLIST_PATH = "/Library/LaunchDaemons/dev.yamine.plist"
40
+ UNIT_PATH = "/etc/systemd/system/yamine.service"
41
+
42
+ # A version-stamped gem payload: the legacy layout the unit must
43
+ # stop referencing.
44
+ VERSIONED_GEM_PATTERN = %r{/gems/yamine-\d+\.\d+\.\d+/}
45
+
46
+ StatInfo = Struct.new(:uid, :mode, :directory)
47
+
48
+ module_function
49
+
50
+ def root_dir
51
+ override = ENV[ROOT_ENV_VAR]
52
+ return override unless override.nil? || override.empty?
53
+
54
+ RUBY_PLATFORM =~ /darwin/ ? MACOS_ROOT : LINUX_ROOT
55
+ end
56
+
57
+ def versions_dir(root = root_dir)
58
+ File.join(root, VERSIONS_DIRNAME)
59
+ end
60
+
61
+ def current_link(root = root_dir)
62
+ File.join(root, CURRENT_LINKNAME)
63
+ end
64
+
65
+ def version_file(root = root_dir)
66
+ File.join(root, VERSION_FILENAME)
67
+ end
68
+
69
+ def allowlist_file(root = root_dir)
70
+ File.join(root, ALLOWLIST_FILENAME)
71
+ end
72
+
73
+ # The version-independent argv the unit and the grant pin: stable
74
+ # across upgrades, never inside a user home, never version-stamped.
75
+ def bin_path(root = root_dir)
76
+ File.join(current_link(root), "bin", "yamine")
77
+ end
78
+
79
+ def gem_root
80
+ File.expand_path("../..", __dir__)
81
+ end
82
+
83
+ # The version recorded at staging time, or nil when nothing valid
84
+ # was ever staged.
85
+ def staged_version(root = root_dir)
86
+ path = version_file(root)
87
+ return nil unless File.file?(path)
88
+
89
+ version = File.read(path).strip
90
+ version.empty? ? nil : version
91
+ rescue SystemCallError
92
+ nil
93
+ end
94
+
95
+ def staged?(root = root_dir)
96
+ File.file?(bin_path(root)) && !staged_version(root).nil?
97
+ end
98
+
99
+ # Only ever stage into (or delete) our own payload root. The guard
100
+ # is structural — leaf name plus a known parent, or the explicit
101
+ # test override — so a bug can never turn stage!/remove_payload!
102
+ # into a recursive delete elsewhere.
103
+ def payload_root?(root = root_dir)
104
+ return false unless File.basename(root) == "yamine"
105
+
106
+ override = ENV[ROOT_ENV_VAR]
107
+ return true if override && !override.empty? && root == override
108
+
109
+ KNOWN_PARENTS.include?(File.dirname(root))
110
+ end
111
+
112
+ # Stage bin+lib from the running gem into the payload root:
113
+ # copy to a fresh version dir, lock ownership/modes, verify BEFORE
114
+ # touching the live `current` link, swap the link atomically,
115
+ # record the version marker, prune superseded versions, verify the
116
+ # whole tree again. Raises Error on any failure — the live payload
117
+ # (if any) is left untouched, and no unit is registered against an
118
+ # unverified tree.
119
+ def stage!(version: Yamine::VERSION, root: root_dir, source: gem_root, state_dir: nil)
120
+ raise Error, "refusing to stage into #{root.inspect} — not a yamine payload root" unless payload_root?(root)
121
+
122
+ FileUtils.mkdir_p(versions_dir(root))
123
+ dest = File.join(versions_dir(root), version)
124
+ tmp = "#{dest}.staging-#{Process.pid}"
125
+ FileUtils.rm_rf(tmp)
126
+ FileUtils.mkdir_p(tmp)
127
+ begin
128
+ %w[bin lib].each do |entry|
129
+ src = File.join(source, entry)
130
+ raise Error, "cannot stage the privileged payload: #{src} is missing" unless File.exist?(src)
131
+
132
+ FileUtils.cp_r(src, tmp)
133
+ end
134
+ normalize_modes!(tmp)
135
+ secure_tree!(tmp)
136
+ verify_tree!(tmp)
137
+ # Promote the verified tree into place, then swing the link:
138
+ # readers always see a complete version dir.
139
+ File.rename(tmp, dest)
140
+ swap_link!(current_link(root), dest)
141
+ write_root_file(version_file(root), "#{version}\n")
142
+ seed_allowlist!(root, state_dir: state_dir)
143
+ prune_versions!(root, keep: dest)
144
+ verify!(root)
145
+ ensure
146
+ FileUtils.rm_rf(tmp)
147
+ end
148
+ version
149
+ end
150
+
151
+ # Remove the staged payload: the link, every version dir, the
152
+ # version marker, and the allowlist — each by exact name, so the
153
+ # removal stays scoped to yamine's own files. The root dir itself
154
+ # is left in place.
155
+ def remove_payload!(root = root_dir)
156
+ raise Error, "refusing to remove #{root.inspect} — not a yamine payload root" unless payload_root?(root)
157
+
158
+ FileUtils.rm_f(current_link(root))
159
+ FileUtils.rm_rf(versions_dir(root))
160
+ FileUtils.rm_f(version_file(root))
161
+ FileUtils.rm_f(allowlist_file(root))
162
+ true
163
+ end
164
+
165
+ # Every path root executes through this payload: the root itself,
166
+ # the entrypoint, the version marker, the allowlist, and each file
167
+ # under the live version dir (symlinks resolved — the check must
168
+ # see the real payload, not the link).
169
+ def root_executed_paths(root = root_dir)
170
+ paths = [root, bin_path(root), version_file(root), allowlist_file(root)]
171
+ target = current_target(root)
172
+ if target && File.directory?(target)
173
+ Dir.glob(File.join(target, "**", "*"), File::FNM_DOTMATCH).each do |path|
174
+ next if [".", ".."].include?(File.basename(path))
175
+
176
+ paths << path
177
+ end
178
+ end
179
+ paths.uniq
180
+ end
181
+
182
+ def current_target(root = root_dir)
183
+ link = current_link(root)
184
+ return nil unless File.symlink?(link)
185
+
186
+ File.realpath(link)
187
+ rescue SystemCallError
188
+ nil
189
+ end
190
+
191
+ # Fail closed: every root-executed path must exist, be root-owned,
192
+ # and not be group/other-writable. Raises Error naming each
193
+ # problem; returns true when the tree is safe to register.
194
+ def verify!(root = root_dir)
195
+ problems = []
196
+ paths = root_executed_paths(root)
197
+ missing = paths.reject { |p| File.exist?(p) || File.symlink?(p) }
198
+ problems += missing.map { |p| "#{p} is missing" }
199
+ (paths - missing).each do |path|
200
+ problems << "#{path} is not root-owned" unless root_owned?(path)
201
+ problems << "#{path} is group/other-writable" unless world_safe?(path)
202
+ end
203
+ raise Error, "refusing the privileged payload: #{problems.join("; ")}" unless problems.empty?
204
+
205
+ true
206
+ end
207
+
208
+ # Single ownership seam (stubbed in tests): follows symlinks so
209
+ # the check always sees the real payload.
210
+ def stat_info(path)
211
+ st = File.stat(path)
212
+ StatInfo.new(st.uid, st.mode, st.directory?)
213
+ end
214
+
215
+ def root_owned?(path)
216
+ stat_info(path).uid.zero?
217
+ rescue SystemCallError
218
+ false
219
+ end
220
+
221
+ def world_safe?(path)
222
+ (stat_info(path).mode & 0o022).zero?
223
+ rescue SystemCallError
224
+ false
225
+ end
226
+
227
+ # The payload argv a unit file references, or nil when the unit
228
+ # does not run yamine at all. Pure string parsing — hermetic.
229
+ def payload_ref_from_plist(xml)
230
+ strings = xml.scan(%r{<string>(.*?)</string>}m).flatten.map(&:strip)
231
+ strings.find { |s| s.end_with?("bin/yamine") }
232
+ end
233
+
234
+ def payload_ref_from_unit(text)
235
+ line = text.each_line.find { |l| l.strip.start_with?("ExecStart=") }
236
+ return nil unless line
237
+
238
+ line.strip.sub(/\AExecStart=/, "").split(/\s+/)[1]
239
+ end
240
+
241
+ # A legacy install: the unit's payload path points into a user
242
+ # home or at a version-stamped gem directory — user-writable code
243
+ # running as root. The staged path is never legacy.
244
+ def legacy_ref?(ref, home: default_home)
245
+ return false if ref.nil? || ref.empty?
246
+ return false if ref == bin_path
247
+ return true if ref.match?(VERSIONED_GEM_PATTERN)
248
+ return true if ref.start_with?(home + "/")
249
+
250
+ false
251
+ end
252
+
253
+ # Which payload the installed unit (if any) runs: the plist on
254
+ # macOS, the systemd unit on Linux. Nil when no unit is installed
255
+ # or when it references nothing yamine-shaped.
256
+ def installed_payload_ref(plist_path: PLIST_PATH, unit_path: UNIT_PATH)
257
+ if RUBY_PLATFORM =~ /darwin/
258
+ return nil unless File.file?(plist_path)
259
+
260
+ begin
261
+ payload_ref_from_plist(File.read(plist_path))
262
+ rescue SystemCallError
263
+ nil
264
+ end
265
+ else
266
+ return nil unless File.file?(unit_path)
267
+
268
+ begin
269
+ payload_ref_from_unit(File.read(unit_path))
270
+ rescue SystemCallError
271
+ nil
272
+ end
273
+ end
274
+ end
275
+
276
+ def service_unit_present?(plist_path: PLIST_PATH, unit_path: UNIT_PATH)
277
+ path = RUBY_PLATFORM =~ /darwin/ ? plist_path : unit_path
278
+ File.file?(path)
279
+ end
280
+
281
+ # Domains hosts sync may write, besides *.localhost (always
282
+ # allowed — RFC 6761 reserves .localhost to loopback, so it can
283
+ # never hijack a real domain). Root-owned and read by the elevated
284
+ # sync, so user-space writes cannot widen it.
285
+ def allowed_tlds(root = root_dir)
286
+ path = allowlist_file(root)
287
+ return ["localhost"] unless File.file?(path)
288
+
289
+ ["localhost"] | read_tld_list(path)
290
+ rescue SystemCallError
291
+ ["localhost"]
292
+ end
293
+
294
+ # The interpreter the unit runs under, and whether user-space
295
+ # writes can change it. This machine has no root-owned Ruby new
296
+ # enough for the gem, so the daemon necessarily runs a
297
+ # user-writable interpreter today — reported truthfully, never
298
+ # papered over.
299
+ def ruby_info(ruby: RbConfig.ruby, home: default_home)
300
+ { path: ruby, writable: ruby_writable?(ruby, home: home) }
301
+ end
302
+
303
+ def ruby_writable?(ruby = RbConfig.ruby, home: default_home)
304
+ return true if ruby.start_with?(home + "/")
305
+
306
+ begin
307
+ st = File.stat(ruby)
308
+ return true if st.uid != 0
309
+ return true unless (st.mode & 0o022).zero?
310
+
311
+ false
312
+ rescue SystemCallError
313
+ true
314
+ end
315
+ end
316
+
317
+ def default_home
318
+ Yamine::Certs.home
319
+ end
320
+
321
+ # Staging helpers (public so tests can pin the failure modes, but
322
+ # only ever called from stage! above).
323
+
324
+ # Root-own the staged tree and lock modes: dirs 0755, files 0644,
325
+ # the entrypoint 0755. The chown only succeeds as root; anywhere
326
+ # else it is skipped and verify_tree! below fails closed.
327
+ def secure_tree!(dir)
328
+ FileUtils.chown_R(0, 0, dir)
329
+ rescue SystemCallError, ArgumentError
330
+ nil
331
+ end
332
+
333
+ def verify_tree!(dir)
334
+ problems = []
335
+ Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
336
+ next if [".", ".."].include?(File.basename(path))
337
+
338
+ problems << "#{path} is not root-owned" unless root_owned?(path)
339
+ problems << "#{path} is group/other-writable" unless world_safe?(path)
340
+ end
341
+ raise Error, "refusing the privileged payload: #{problems.join("; ")}" unless problems.empty?
342
+
343
+ true
344
+ end
345
+
346
+ # dirs keep their copied modes here; normalize explicitly so the
347
+ # staged tree never inherits a permissive umask from the gem dir.
348
+ def normalize_modes!(dir)
349
+ Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
350
+ next if [".", ".."].include?(File.basename(path))
351
+
352
+ if File.directory?(path) && !File.symlink?(path)
353
+ File.chmod(0o755, path)
354
+ elsif File.file?(path) && !File.symlink?(path)
355
+ File.chmod(path == File.join(dir, "bin", "yamine") ? 0o755 : 0o644, path)
356
+ end
357
+ end
358
+ rescue SystemCallError => e
359
+ raise Error, "cannot lock down the staged payload: #{e.message}"
360
+ end
361
+
362
+ # Atomically point `current` at the new version dir: build the
363
+ # symlink aside, then rename over the old link. Readers (the unit,
364
+ # the grant) always see a complete tree.
365
+ def swap_link!(link, dest)
366
+ tmp_link = "#{link}.link-#{Process.pid}"
367
+ FileUtils.rm_f(tmp_link)
368
+ File.symlink(dest, tmp_link)
369
+ File.rename(tmp_link, link)
370
+ end
371
+
372
+ def write_root_file(path, content)
373
+ FileUtils.mkdir_p(File.dirname(path))
374
+ File.write(path, content)
375
+ File.chmod(0o644, path)
376
+ FileUtils.chown(0, 0, path)
377
+ rescue SystemCallError, ArgumentError
378
+ nil
379
+ end
380
+
381
+ # Seed the root-owned allowlist: localhost plus whatever dev TLDs
382
+ # the machine already serves (union with entries already there —
383
+ # an install never narrows what a human previously allowed).
384
+ def seed_allowlist!(root, state_dir: nil)
385
+ entries = ["localhost"]
386
+ begin
387
+ entries |= read_tld_list(allowlist_file(root)) if File.file?(allowlist_file(root))
388
+ rescue SystemCallError
389
+ nil
390
+ end
391
+ dir = state_dir || Yamine::Certs.state_dir
392
+ tlds_path = File.join(dir, "proxy.tlds")
393
+ begin
394
+ entries |= read_tld_list(tlds_path) if File.file?(tlds_path)
395
+ rescue SystemCallError
396
+ nil
397
+ end
398
+ write_root_file(allowlist_file(root), "#{entries.sort.join("\n")}\n")
399
+ end
400
+
401
+ def read_tld_list(path)
402
+ File.readlines(path).map { |l| l.strip.downcase }.reject(&:empty?)
403
+ .select { |t| Yamine::Sanitize.valid_tld?(t) }.uniq
404
+ rescue SystemCallError
405
+ []
406
+ end
407
+
408
+ def prune_versions!(root, keep:)
409
+ Dir.glob(File.join(versions_dir(root), "*")).each do |path|
410
+ next if path == keep || path == "#{keep}.staging-#{Process.pid}"
411
+
412
+ FileUtils.rm_rf(path) if File.directory?(path)
413
+ end
414
+ Dir.glob(File.join(versions_dir(root), "*.staging-*")).each do |path|
415
+ FileUtils.rm_rf(path)
416
+ end
417
+ end
418
+ end
419
+ end