rkseal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RKSeal
6
+ # Thin adapter over the `kubectl` binary.
7
+ #
8
+ # Each public method maps to one kubectl invocation. Methods return raw
9
+ # strings (JSON / context name) or nothing; this adapter does not parse the
10
+ # Secret into the domain model -- {RKSeal::Secret.from_kubectl_json} does that.
11
+ #
12
+ # As with {RKSeal::Kubeseal}, all process execution funnels through one
13
+ # private runner that is the single stub seam for unit tests. The runner must
14
+ # never log Secret contents.
15
+ class Kubectl
16
+ BINARY = "kubectl"
17
+
18
+ # kubectl prints this token to stderr when a resource is absent. Matched
19
+ # case-insensitively to map the failure onto {NotFoundError}.
20
+ NOT_FOUND_MARKER = "notfound"
21
+
22
+ # @param binary [String] override the executable name/path (testing/env).
23
+ def initialize(binary: BINARY)
24
+ @binary = binary
25
+ end
26
+
27
+ # Verify the kubectl binary is present and executable; raise otherwise.
28
+ #
29
+ # @return [void]
30
+ # @raise [RKSeal::DependencyMissingError] if `kubectl` is not on PATH.
31
+ def ensure_available!
32
+ return if executable_on_path?(@binary)
33
+
34
+ raise DependencyMissingError,
35
+ "kubectl not found on PATH (looked for #{@binary.inspect}). " \
36
+ "Install it from https://kubernetes.io/docs/tasks/tools/."
37
+ end
38
+
39
+ # Read a Secret from the cluster as JSON
40
+ # (`kubectl get secret <name> -n <namespace> -o json`). This is the only way
41
+ # to recover the *current* plaintext of an existing SealedSecret, so it
42
+ # drives the `edit` flow.
43
+ #
44
+ # @param name [String]
45
+ # @param namespace [String]
46
+ # @return [String] the JSON document kubectl prints on stdout.
47
+ # @raise [RKSeal::NotFoundError] if the Secret does not exist (kubectl
48
+ # "NotFound"); the message must point the user at `rkseal create`.
49
+ # @raise [RKSeal::CommandError] on any other kubectl failure (e.g. cluster
50
+ # unreachable, unknown namespace).
51
+ def get_secret(name:, namespace:)
52
+ run("get", "secret", name, "-n", namespace, "-o", "json")
53
+ rescue CommandError => e
54
+ raise unless not_found?(e.stderr)
55
+
56
+ raise NotFoundError,
57
+ "Secret #{name.inspect} not found in namespace #{namespace.inspect}. " \
58
+ "Use `rkseal create #{namespace} #{name}` to author it first."
59
+ end
60
+
61
+ # Read a SealedSecret from the cluster as JSON
62
+ # (`kubectl get sealedsecret <name> -n <namespace> -o json`). The `edit` flow
63
+ # consults this to recover the existing seal's scope when it is not otherwise
64
+ # known; callers rescue {NotFoundError} to fall back to the local file.
65
+ #
66
+ # @param name [String]
67
+ # @param namespace [String]
68
+ # @return [String] the JSON document kubectl prints on stdout.
69
+ # @raise [RKSeal::NotFoundError] if the SealedSecret does not exist.
70
+ # @raise [RKSeal::CommandError] on any other kubectl failure (e.g. cluster
71
+ # unreachable, unknown namespace, CRD not installed).
72
+ def get_sealedsecret(name:, namespace:)
73
+ run("get", "sealedsecret", name, "-n", namespace, "-o", "json")
74
+ rescue CommandError => e
75
+ raise unless not_found?(e.stderr)
76
+
77
+ raise NotFoundError,
78
+ "SealedSecret #{name.inspect} not found in namespace #{namespace.inspect}."
79
+ end
80
+
81
+ # List SealedSecrets as JSON (`kubectl get sealedsecret -o json`), scoped to
82
+ # one namespace (`-n <namespace>`) or across all namespaces (`-A`) when none
83
+ # is given. Drives the `list` flow.
84
+ #
85
+ # An empty namespace is not an error: kubectl returns a List object with an
86
+ # empty `items: []`, so this does NOT map NotFound -- only operational
87
+ # failures (cluster unreachable, CRD absent) surface, as {CommandError}.
88
+ #
89
+ # @param namespace [String, nil] a single namespace, or nil for all.
90
+ # @return [String] the JSON List document kubectl prints on stdout.
91
+ # @raise [RKSeal::CommandError] on any kubectl failure.
92
+ def list_sealedsecrets(namespace: nil)
93
+ scope = namespace ? ["-n", namespace] : ["-A"]
94
+ run("get", "sealedsecret", *scope, "-o", "json")
95
+ end
96
+
97
+ # Apply a manifest file to the cluster (`kubectl apply -f <file>`). Only the
98
+ # `edit` deploy step calls this, and only after {RKSeal::ContextGuard} has
99
+ # approved the active context.
100
+ #
101
+ # @param file [String] path to the SealedSecret manifest to apply.
102
+ # @return [String] kubectl's stdout (the apply result line).
103
+ # @raise [RKSeal::CommandError] if apply fails.
104
+ def apply(file:)
105
+ run("apply", "-f", file)
106
+ end
107
+
108
+ # Return the active kube context (`kubectl config current-context`). Used by
109
+ # {RKSeal::ContextGuard} to gate deploys.
110
+ #
111
+ # @return [String] the current context name (whitespace stripped).
112
+ # @raise [RKSeal::CommandError] if kubectl cannot report a context.
113
+ def current_context
114
+ run("config", "current-context").strip
115
+ end
116
+
117
+ private
118
+
119
+ # Whether kubectl's stderr indicates the resource was absent ("NotFound").
120
+ def not_found?(stderr)
121
+ return false unless stderr
122
+
123
+ stderr.downcase.include?(NOT_FOUND_MARKER)
124
+ end
125
+
126
+ # The single shell-out seam for this adapter. Uses Open3.capture3 with an
127
+ # argv array (never a shell string) so user-supplied names/namespaces can
128
+ # never be interpreted by a shell -- no injection surface. On a non-zero
129
+ # exit it raises CommandError carrying the scrubbed command label, status,
130
+ # and stderr.
131
+ #
132
+ # SECURITY: any `stdin` is piped to the child but is NEVER echoed into the
133
+ # command label or error message; only argv (subcommands, names, paths) is
134
+ # surfaced.
135
+ #
136
+ # @param argv [Array<String>] arguments passed after the binary name.
137
+ # @param stdin [String, nil] data piped to the child process's stdin.
138
+ # @return [String] captured stdout.
139
+ # @raise [RKSeal::CommandError] on a non-zero exit.
140
+ def run(*argv, stdin: nil)
141
+ stdout, stderr, status = Open3.capture3(@binary, *argv, stdin_data: stdin || "")
142
+ return stdout if status.success?
143
+
144
+ raise CommandError.new(
145
+ "kubectl failed (exit #{status.exitstatus}): #{stderr.strip}",
146
+ command: command_label(argv),
147
+ status: status.exitstatus,
148
+ stderr: stderr
149
+ )
150
+ end
151
+
152
+ # Whether `name` resolves to an executable file on PATH (or is itself an
153
+ # executable path).
154
+ def executable_on_path?(name)
155
+ return File.executable?(name) if name.include?(File::SEPARATOR)
156
+
157
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
158
+ File.executable?(File.join(dir, name))
159
+ end
160
+ end
161
+
162
+ # A safe, human-readable label for error messages: the binary plus its argv.
163
+ # Contains only subcommands/paths -- never stdin -- so it cannot leak data.
164
+ def command_label(argv)
165
+ [@binary, *argv].join(" ")
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module RKSeal
8
+ # Thin adapter over the `kubeseal` binary.
9
+ #
10
+ # Owns everything kubeseal-flag-shaped: scope, certificate source, and the
11
+ # controller's name/namespace. Each public method maps to one kubeseal
12
+ # invocation and returns its stdout (the produced SealedSecret YAML or a PEM
13
+ # certificate). Nothing here parses YAML or knows about the domain model --
14
+ # callers pass in manifest text and get sealed text back.
15
+ #
16
+ # Developed against kubeseal **v0.36.6**; flag names below assume that CLI.
17
+ #
18
+ # All process execution funnels through one private runner so that unit tests
19
+ # stub a single seam (or stub the public methods directly). The runner must
20
+ # never echo stdin (the plaintext Secret) into logs or error messages.
21
+ #
22
+ # rubocop:disable Metrics/ClassLength -- the inline {CertCache} is co-located
23
+ # here by design (the cert cache is intrinsic to this adapter and must not add
24
+ # a new top-level require); that nested class accounts for the extra lines.
25
+ class Kubeseal
26
+ BINARY = "kubeseal"
27
+
28
+ # Allowed sealing scopes, mapped to their kubeseal `--scope` argument.
29
+ SCOPES = {
30
+ strict: "strict",
31
+ namespace_wide: "namespace-wide",
32
+ cluster_wide: "cluster-wide"
33
+ }.freeze
34
+
35
+ # kubeseal's own defaults for the controller's identity. Used to name the
36
+ # cache entry consistently when the caller does not override them, so a run
37
+ # with implicit defaults and a run with explicit-but-identical flags share
38
+ # one cached cert.
39
+ DEFAULT_CONTROLLER_NAME = "sealed-secrets-controller"
40
+ DEFAULT_CONTROLLER_NAMESPACE = "kube-system"
41
+
42
+ # Substring kubeseal prints to stderr when the controller could decrypt-test
43
+ # the SealedSecret but it is NOT valid. Anything else on a non-zero
44
+ # `--validate` exit is treated as operational (CommandError), not a verdict.
45
+ VALIDATION_FAILURE_MARKER = "unable to decrypt"
46
+
47
+ # @param binary [String] override the executable name/path (testing/env).
48
+ # @param controller_name [String, nil] `--controller-name` value.
49
+ # @param controller_namespace [String, nil] `--controller-namespace` value.
50
+ # @param cert [String, nil] `--cert <file|URL>` source; when nil and no env
51
+ # cert is present, the cert is fetched from the controller and cached.
52
+ # @param refresh_cert [Boolean] when true, ignore any cached cert and
53
+ # overwrite it with a freshly fetched one (wired to `--refresh-cert`).
54
+ def initialize(binary: BINARY, controller_name: nil, controller_namespace: nil,
55
+ cert: nil, refresh_cert: false)
56
+ @binary = binary
57
+ @controller_name = controller_name
58
+ @controller_namespace = controller_namespace
59
+ @cert = cert
60
+ @refresh_cert = refresh_cert
61
+ end
62
+
63
+ # Verify the kubeseal binary is present and executable; raise otherwise.
64
+ # Called early so the flow fails fast on a missing dependency.
65
+ #
66
+ # @return [void]
67
+ # @raise [RKSeal::DependencyMissingError] if `kubeseal` is not on PATH.
68
+ def ensure_available!
69
+ return if executable_on_path?(@binary)
70
+
71
+ raise DependencyMissingError,
72
+ "kubeseal not found on PATH (looked for #{@binary.inspect}). " \
73
+ "Install it from https://github.com/bitnami-labs/sealed-secrets/releases."
74
+ end
75
+
76
+ # Resolve the encryption certificate up front so a flow fails fast before any
77
+ # editor opens. When an offline cert is configured (`--cert` or the
78
+ # `SEALED_SECRETS_CERT` env var) nothing is contacted. Otherwise the cert is
79
+ # resolved through the on-disk cache: a cached PEM is reused as-is, otherwise
80
+ # it is fetched from the live controller and written to the cache (which is
81
+ # what makes a subsequent {#seal} offline). `refresh_cert: true` skips the
82
+ # cached copy and refetches.
83
+ #
84
+ # @return [void]
85
+ # @raise [RKSeal::CommandError] if no offline cert is configured and the
86
+ # controller is unreachable (the underlying `--fetch-cert` exits non-zero).
87
+ def ensure_cert!
88
+ return if offline_cert?
89
+
90
+ resolve_cached_cert_path
91
+ nil
92
+ end
93
+
94
+ # Seal a Secret manifest into a SealedSecret.
95
+ #
96
+ # Pipes `manifest_yaml` to kubeseal on stdin with `-o yaml` and the resolved
97
+ # `--scope`. The certificate is resolved offline-first: an explicit `--cert`
98
+ # or the cached controller PEM is passed via `--cert` (no API round-trip);
99
+ # only when neither is available does kubeseal fall back to the env var or
100
+ # the controller itself. Returns the SealedSecret YAML on stdout.
101
+ #
102
+ # @param manifest_yaml [String] a full Secret manifest (from
103
+ # {RKSeal::Secret#to_manifest}).
104
+ # @param scope [Symbol] one of {SCOPES} keys; defaults to :strict.
105
+ # @return [String] SealedSecret YAML.
106
+ # @raise [RKSeal::InvalidInputError] if scope is unknown.
107
+ # @raise [RKSeal::CommandError] if kubeseal exits non-zero (e.g. controller
108
+ # unreachable, bad cert).
109
+ def seal(manifest_yaml, scope: :strict)
110
+ # `-o yaml` is mandatory: kubeseal defaults to JSON, so without it the
111
+ # output written to `<name>.yaml` would actually contain JSON.
112
+ argv = ["--scope", scope_flag(scope), "-o", "yaml"]
113
+ cert_path = resolved_cert_path
114
+ argv += ["--cert", cert_path] if cert_path
115
+ argv += controller_flags
116
+
117
+ run(*argv, stdin: manifest_yaml)
118
+ end
119
+
120
+ # Validate that a SealedSecret can be decrypted by the controller
121
+ # (`kubeseal --validate`, SealedSecret piped on stdin). Contacts the cluster:
122
+ # the controller performs the decrypt-test.
123
+ #
124
+ # kubeseal v0.36.6 exits 0 when valid and non-zero otherwise, printing the
125
+ # reason to stderr. A non-zero exit whose stderr names a decrypt failure is a
126
+ # validity verdict ({ValidationError}); any other non-zero exit (missing
127
+ # binary, unreachable cluster, controller service not found) is operational
128
+ # ({CommandError}) and says nothing about the SealedSecret itself.
129
+ #
130
+ # @param sealed_secret_yaml [String] a SealedSecret manifest.
131
+ # @return [true] when the controller can decrypt it.
132
+ # @raise [RKSeal::ValidationError] when the controller rejects it as invalid.
133
+ # @raise [RKSeal::CommandError] on operational failures.
134
+ def validate(sealed_secret_yaml)
135
+ run("--validate", *controller_flags, stdin: sealed_secret_yaml)
136
+ true
137
+ rescue CommandError => e
138
+ raise unless validation_failure?(e.stderr)
139
+
140
+ raise ValidationError,
141
+ "SealedSecret failed validation: #{e.stderr.strip}"
142
+ end
143
+
144
+ # Fetch the controller's public certificate (`kubeseal --fetch-cert`) so it
145
+ # can be cached and reused, avoiding an API round-trip per seal.
146
+ #
147
+ # NOTE: unlike {#seal}, this method contacts the cluster API by design.
148
+ #
149
+ # @return [String] the certificate in PEM format.
150
+ # @raise [RKSeal::CommandError] if the controller is unreachable.
151
+ def fetch_cert
152
+ run("--fetch-cert", *controller_flags)
153
+ end
154
+
155
+ # Blind-append freshly-encrypted items to an existing SealedSecret file
156
+ # (`kubeseal --merge-into <file>`). Does NOT decrypt anything: it appends or
157
+ # overwrites the items in the input Secret while leaving every other sealed
158
+ # entry untouched. This is what powers the offline `edit --local` flow,
159
+ # where kept keys must stay byte-for-byte unchanged.
160
+ #
161
+ # The certificate is resolved offline-first, exactly like {#seal}: an
162
+ # explicit `--cert` or the cached controller PEM is passed via `--cert` (no
163
+ # API round-trip); only when neither is available does kubeseal fall back to
164
+ # the env var or the controller. The output format is inherited from the
165
+ # existing file, so `-o` is NOT forced here.
166
+ #
167
+ # @param manifest_yaml [String] Secret manifest with the items to add.
168
+ # @param file [String] path to the existing SealedSecret to merge into.
169
+ # @param scope [Symbol] sealing scope for the new items.
170
+ # @return [void] mutates `file` in place.
171
+ # @raise [RKSeal::CommandError] on kubeseal failure.
172
+ def merge_into(manifest_yaml, file:, scope: :strict)
173
+ argv = ["--merge-into", file, "--scope", scope_flag(scope)]
174
+ cert_path = resolved_cert_path
175
+ argv += ["--cert", cert_path] if cert_path
176
+ argv += controller_flags
177
+
178
+ run(*argv, stdin: manifest_yaml)
179
+ nil
180
+ end
181
+
182
+ # Upgrade an existing SealedSecret to the controller's newest key without
183
+ # exposing plaintext (`kubeseal --re-encrypt`). Out of scope for the initial
184
+ # create/edit flows but part of the adapter surface.
185
+ #
186
+ # NOTE: contacts the cluster API by design.
187
+ #
188
+ # @param sealed_yaml [String] an existing SealedSecret manifest.
189
+ # @return [String] the re-encrypted SealedSecret YAML.
190
+ # @raise [RKSeal::CommandError] on kubeseal failure.
191
+ def re_encrypt(sealed_yaml)
192
+ run("--re-encrypt", "-o", "yaml", *controller_flags, stdin: sealed_yaml)
193
+ end
194
+
195
+ private
196
+
197
+ # Translate a scope symbol into its kubeseal `--scope` argument.
198
+ #
199
+ # @raise [RKSeal::InvalidInputError] for an unknown scope.
200
+ def scope_flag(scope)
201
+ SCOPES.fetch(scope) do
202
+ raise InvalidInputError,
203
+ "Unknown scope #{scope.inspect}; expected one of #{SCOPES.keys.inspect}."
204
+ end
205
+ end
206
+
207
+ # Whether an offline certificate source is configured, meaning {#ensure_cert!}
208
+ # need not contact the controller. Either the injected `--cert` value or a
209
+ # non-blank `SEALED_SECRETS_CERT` env var counts.
210
+ def offline_cert?
211
+ return true if @cert
212
+
213
+ !env_cert.strip.empty?
214
+ end
215
+
216
+ # The `SEALED_SECRETS_CERT` env var, or an empty string.
217
+ def env_cert
218
+ ENV.fetch("SEALED_SECRETS_CERT", "")
219
+ end
220
+
221
+ # Resolve a cert file/URL to pass to `--cert`, or nil to let kubeseal fall
222
+ # back to its own sources. Precedence: explicit `--cert` > env var (kubeseal
223
+ # reads SEALED_SECRETS_CERT itself, so we pass nil) > cached/fetched PEM.
224
+ def resolved_cert_path
225
+ return @cert if @cert
226
+ return nil unless env_cert.strip.empty?
227
+
228
+ resolve_cached_cert_path
229
+ end
230
+
231
+ # Return the path to a usable cached controller PEM, fetching and writing it
232
+ # first if absent (or if a refresh was requested). The cert is public, so the
233
+ # cache file is world-readable.
234
+ def cert_cache
235
+ @cert_cache ||= CertCache.new(
236
+ controller_namespace: @controller_namespace || DEFAULT_CONTROLLER_NAMESPACE,
237
+ controller_name: @controller_name || DEFAULT_CONTROLLER_NAME
238
+ )
239
+ end
240
+
241
+ def resolve_cached_cert_path
242
+ return cert_cache.path if !@refresh_cert && cert_cache.exist?
243
+
244
+ cert_cache.write(fetch_cert)
245
+ end
246
+
247
+ # Whether kubeseal's stderr from a failed `--validate` is a validity verdict
248
+ # (the controller could not decrypt) rather than an operational failure.
249
+ def validation_failure?(stderr)
250
+ return false unless stderr
251
+
252
+ stderr.downcase.include?(VALIDATION_FAILURE_MARKER)
253
+ end
254
+
255
+ # `--controller-name` / `--controller-namespace` flags when configured.
256
+ def controller_flags
257
+ flags = []
258
+ flags += ["--controller-name", @controller_name] if @controller_name
259
+ flags += ["--controller-namespace", @controller_namespace] if @controller_namespace
260
+ flags
261
+ end
262
+
263
+ # The single shell-out seam for this adapter. Uses Open3.capture3 with an
264
+ # argv array (never a shell string) so user-supplied values can never be
265
+ # interpreted by a shell -- no injection surface. On a non-zero exit it
266
+ # raises CommandError carrying the scrubbed command label, status, and
267
+ # stderr.
268
+ #
269
+ # SECURITY: `stdin` (the plaintext Secret manifest) is piped to the child
270
+ # process but is NEVER included in the command label or any error message.
271
+ # Only argv -- which holds flags and file paths, not secret values -- is
272
+ # surfaced.
273
+ #
274
+ # @param argv [Array<String>] arguments passed after the binary name.
275
+ # @param stdin [String, nil] data piped to the child process's stdin.
276
+ # @return [String] captured stdout.
277
+ # @raise [RKSeal::CommandError] on a non-zero exit.
278
+ def run(*argv, stdin: nil)
279
+ stdout, stderr, status = Open3.capture3(@binary, *argv, stdin_data: stdin || "")
280
+ return stdout if status.success?
281
+
282
+ raise CommandError.new(
283
+ "kubeseal failed (exit #{status.exitstatus}): #{stderr.strip}",
284
+ command: command_label(argv),
285
+ status: status.exitstatus,
286
+ stderr: stderr
287
+ )
288
+ end
289
+
290
+ # Whether `name` resolves to an executable file on PATH (or is itself an
291
+ # executable path).
292
+ def executable_on_path?(name)
293
+ return File.executable?(name) if name.include?(File::SEPARATOR)
294
+
295
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
296
+ File.executable?(File.join(dir, name))
297
+ end
298
+ end
299
+
300
+ # A safe, human-readable label for error messages: the binary plus its argv.
301
+ # Contains only flags/paths -- never stdin -- so it cannot leak secrets.
302
+ def command_label(argv)
303
+ [@binary, *argv].join(" ")
304
+ end
305
+
306
+ # On-disk cache for the controller's PUBLIC certificate, so repeated seals do
307
+ # not each hit the cluster for `--fetch-cert`. The cert is public, hence the
308
+ # world-readable 0644 perms. One entry per controller identity, under the XDG
309
+ # cache dir:
310
+ # ${XDG_CACHE_HOME:-$HOME/.cache}/rkseal/<namespace>/<name>.pem
311
+ #
312
+ # The namespace is a path segment rather than a `<namespace>-<name>` prefix
313
+ # so two distinct identities can never collide on one file (e.g. `a-b`/`c`
314
+ # vs `a`/`b-c`); a DNS-1123 name contains no `/`, so the layout is
315
+ # unambiguous. Writes go through a temp file + atomic rename so a concurrent
316
+ # seal never reads a half-written cert.
317
+ #
318
+ # Defined inline (not a separate file) so this adapter stays self-contained
319
+ # and adds no new top-level require.
320
+ class CertCache
321
+ DIR_PERMS = 0o755
322
+ FILE_PERMS = 0o644
323
+
324
+ # @param controller_namespace [String]
325
+ # @param controller_name [String]
326
+ def initialize(controller_namespace:, controller_name:)
327
+ @controller_namespace = controller_namespace
328
+ @controller_name = controller_name
329
+ end
330
+
331
+ # @return [String] absolute path to this controller's cached PEM.
332
+ def path
333
+ File.join(cache_dir, @controller_namespace, "#{@controller_name}.pem")
334
+ end
335
+
336
+ # @return [Boolean] whether a cached PEM already exists.
337
+ def exist?
338
+ File.exist?(path)
339
+ end
340
+
341
+ # @return [String] the cached PEM contents.
342
+ def read
343
+ File.read(path)
344
+ end
345
+
346
+ # Persist a freshly fetched PEM (overwriting any existing entry) and return
347
+ # its path so the caller can hand it to `--cert`.
348
+ #
349
+ # @param pem [String] certificate contents.
350
+ # @return [String] the cache path that now holds the PEM.
351
+ def write(pem)
352
+ FileUtils.mkdir_p(File.dirname(path), mode: DIR_PERMS)
353
+ write_atomically(pem)
354
+ path
355
+ end
356
+
357
+ private
358
+
359
+ # Write the PEM to a uniquely-named temp file in the same directory, then
360
+ # rename it over the target. rename(2) is atomic within one filesystem, so
361
+ # a concurrent seal sees either the old cert or the new one -- never a
362
+ # half-written file. The temp file carries the final 0644 perms and is
363
+ # removed if the rename never happens (e.g. an error mid-write).
364
+ def write_atomically(pem)
365
+ tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{SecureRandom.hex(8)}.tmp")
366
+ File.write(tmp, pem)
367
+ File.chmod(FILE_PERMS, tmp)
368
+ File.rename(tmp, path)
369
+ ensure
370
+ File.unlink(tmp) if tmp && File.exist?(tmp)
371
+ end
372
+
373
+ # ${XDG_CACHE_HOME:-$HOME/.cache}/rkseal
374
+ def cache_dir
375
+ base = ENV.fetch("XDG_CACHE_HOME", nil)
376
+ base = File.join(Dir.home, ".cache") if base.nil? || base.strip.empty?
377
+ File.join(base, "rkseal")
378
+ end
379
+ end
380
+ end
381
+ # rubocop:enable Metrics/ClassLength
382
+ end