decdn-common 0.0.1

Shared types for deCDN binaries: config schema, identity, CLI args
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
//! Node identity: load or generate a persistent ed25519 `SecretKey`.
//!
//! The key is stored as 32 raw bytes at `<data_dir>/node.secret`. On Unix the
//! temp file is opened with mode `0600` from creation (no umask-window where
//! the key material is world-readable) and atomically renamed into place.
//!
//! On Unix, permissions are validated on every load to block key-replacement
//! attacks: `data_dir` must be a directory with no group or world permission
//! bits (e.g. `0o700`), and `node.secret` must be a regular file with no group
//! or world permission bits (e.g. `0o600`). Setuid/setgid/sticky bits are not
//! inspected. A world- or group-writable `data_dir` would let any local
//! process replace `node.secret` between load and use. On first run the
//! directory is created with `0o700` via `DirBuilder::mode` (no umask window).
//! Non-Unix platforms skip these checks — POSIX mode bits do not map
//! meaningfully to NTFS ACLs.
//!
//! New keys are built via [`SecretKey::generate`], which draws from
//! the auto-seeded thread-local CSPRNG backed by OS entropy.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, anyhow};
use iroh::{PublicKey, SecretKey, Signature};
use rand::Rng;

const KEY_FILE_NAME: &str = "node.secret";
const KEY_LEN: usize = 32;

/// Mode used when creating `data_dir` on first run.
#[cfg(unix)]
const DATA_DIR_MODE: u32 = 0o700;
/// Any group or other bit set on `data_dir` or the key file is rejected. A
/// single mask for both: key material must be owner-only, and so must its
/// containing directory (a group-writable dir allows the same key-replacement
/// attack as a world-writable one, just with a smaller attacker set).
#[cfg(unix)]
const FORBIDDEN_BITS: u32 = 0o077;

/// Location of the persistent node key within `data_dir`.
pub fn key_path(data_dir: &Path) -> PathBuf {
    data_dir.join(KEY_FILE_NAME)
}

/// Load the node's `SecretKey` from disk **without** generating one when it is
/// absent — the read-only counterpart of [`load_or_generate`], used by
/// `decdn whoami` to report the existing identity without minting a new key.
///
/// Applies the same validation as [`load_or_create`]'s load path: `data_dir`
/// must be a secure directory and `node.secret` a secure 32-byte regular file.
///
/// # Errors
///
/// Returns an error if `node.secret` does not exist; if it is a symlink or
/// other non-regular file, has the wrong size, or has insecure permissions;
/// if `data_dir` is missing, not a directory, or has insecure permissions; if
/// the key path can't be stat'd for reasons other than non-existence; or if
/// reading fails.
pub fn load(data_dir: &Path) -> anyhow::Result<SecretKey> {
    let path = key_path(data_dir);

    // Check `data_dir` first so a clear "invalid data_dir" error beats a
    // confusing "failed to stat key path" when `data_dir` is e.g. a regular
    // file (the `join` would make the key path unstat'able with ENOTDIR).
    match fs::symlink_metadata(data_dir) {
        Ok(_) => validate_data_dir(data_dir)?,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(anyhow!(
                "no node identity: data_dir {} does not exist (key path {})",
                data_dir.display(),
                path.display()
            ));
        }
        Err(e) => {
            return Err(anyhow::Error::new(e)
                .context(format!("failed to stat data_dir {}", data_dir.display())));
        }
    }

    // `symlink_metadata` rather than `path.exists()`: it returns `Ok` for a
    // dangling symlink (so `validate_key_file` rejects it), and it distinguishes
    // `NotFound` from other errors so a storage EIO is not mistaken for an
    // absent key.
    match fs::symlink_metadata(&path) {
        Ok(_) => {
            validate_key_file(&path)?;
            load_from(&path)
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(anyhow!(
            "no node identity at {}; run `decdn key-gen` to create one",
            path.display()
        )),
        Err(e) => {
            Err(anyhow::Error::new(e)
                .context(format!("failed to stat key path {}", path.display())))
        }
    }
}

/// Load the node's `SecretKey` from disk, generating and persisting one if absent.
///
/// Logs the key path at `INFO` when it generates a key. [`load_or_create`] is
/// the same load without the log, for a caller with no subscriber yet.
///
/// # Errors
///
/// As [`load_or_create`].
pub fn load_or_generate(data_dir: &Path) -> anyhow::Result<SecretKey> {
    let loaded = load_or_create(data_dir)?;
    if loaded.generated {
        tracing::info!(path = %key_path(data_dir).display(), "generated new node secret key");
    }
    Ok(loaded.key)
}

/// A node key from [`load_or_create`], and whether that call created it.
#[derive(Debug)]
pub struct LoadedKey {
    /// The node's iroh secret key.
    pub key: SecretKey,
    /// `true` when the call generated and persisted `key`; `false` when it
    /// read an existing key.
    pub generated: bool,
}

/// Load the node's `SecretKey` from disk, generating and persisting one if
/// absent, and report which of the two happened.
///
/// Emits no log, so a caller can run it before its tracing subscriber exists
/// and log [`LoadedKey::generated`] itself. The daemon does this so that its
/// JSON formatter can write `node_id` on every event.
///
/// # Errors
///
/// Returns an error if the file exists but is a symlink or other non-regular
/// file, has the wrong size, or has insecure permissions; if `data_dir` is
/// not a directory or has insecure permissions; if the key path can't be
/// stat'd for reasons other than non-existence; if the directory cannot be
/// created; or if reading/writing fails.
pub fn load_or_create(data_dir: &Path) -> anyhow::Result<LoadedKey> {
    let path = key_path(data_dir);

    // Check `data_dir` first so a clear "invalid data_dir" error beats a
    // confusing "failed to stat key path" when `data_dir` is e.g. a regular
    // file (the `join` would make the key path unstat'able with ENOTDIR).
    // NotFound is fine — the create path below handles it.
    let data_dir_exists = match fs::symlink_metadata(data_dir) {
        Ok(_) => {
            validate_data_dir(data_dir)?;
            true
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
        Err(e) => {
            return Err(anyhow::Error::new(e)
                .context(format!("failed to stat data_dir {}", data_dir.display())));
        }
    };

    // `symlink_metadata` rather than `path.exists()`: it returns `Ok` for a
    // dangling symlink (so `validate_key_file` rejects it instead of `rename`
    // overwriting the target), and we distinguish `NotFound` from other errors
    // so a storage EIO doesn't silently mask an existing key and trigger a
    // rogue regeneration.
    if data_dir_exists {
        match fs::symlink_metadata(&path) {
            Ok(_) => {
                validate_key_file(&path)?;
                return Ok(LoadedKey {
                    key: load_from(&path)?,
                    generated: false,
                });
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => {
                return Err(anyhow::Error::new(e)
                    .context(format!("failed to stat key path {}", path.display())));
            }
        }
    }

    create_data_dir_secure(data_dir)?;
    // Re-validate even after we just validated above: `create_data_dir_secure`
    // is a no-op on a pre-existing dir, so this branch runs on already-checked
    // state — but if the dir did not exist before, this is the *only* check.
    validate_data_dir(data_dir)?;

    let key = fresh_secret_key();
    write_atomic(&path, &key.to_bytes())?;
    Ok(LoadedKey {
        key,
        generated: true,
    })
}

/// Validate (or create+validate) `data_dir` with `0o700` semantics: if it
/// exists, the permission policy is enforced; if it's `NotFound`, it's
/// created securely and then validated. Other stat errors propagate.
///
/// Public surface so `decdn_incentive::eth_identity::generate_and_persist`
/// (the eth keystore writer, #406) can share the same `data_dir` hardening
/// path as `node.secret`. The lower-level `validate_data_dir` /
/// `create_data_dir_secure` helpers stay `pub(crate)` — callers outside
/// this crate should reach for `ensure_data_dir` instead.
pub fn ensure_data_dir(data_dir: &Path) -> anyhow::Result<()> {
    match fs::symlink_metadata(data_dir) {
        Ok(_) => validate_data_dir(data_dir),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            create_data_dir_secure(data_dir)?;
            validate_data_dir(data_dir)
        }
        Err(e) => Err(anyhow::Error::new(e)
            .context(format!("failed to stat data_dir {}", data_dir.display()))),
    }
}

/// Rename `path` to `<filename>.bak.<unix_ts>` so an in-place key rotation
/// preserves the prior key material rather than destroying it. Required by
/// `appendix-operator-key-rotation.md` §1 step 9 ("Archive the old iroh
/// keystore offline. Retain it for at least `MAX_EVIDENCE_AGE_US`") and the
/// §5 rollback path ("Repoint config at old keystore"). The operator can
/// later move the `.bak` file offline; deleting it is up to them.
///
/// Bails if the bak path already exists (two `key-gen` invocations within
/// the same second) rather than silently clobbering — operator removes the
/// stale archive manually and retries.
///
/// Returns the new bak path on success.
pub fn move_aside(path: &Path) -> anyhow::Result<PathBuf> {
    use std::time::{SystemTime, UNIX_EPOCH};
    let unix_ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.as_secs());
    let file_name = path
        .file_name()
        .and_then(|s| s.to_str())
        .ok_or_else(|| anyhow!("invalid path (no UTF-8 filename): {}", path.display()))?;
    let parent = path
        .parent()
        .ok_or_else(|| anyhow!("path has no parent directory: {}", path.display()))?;
    let bak = parent.join(format!("{file_name}.bak.{unix_ts}"));
    anyhow::ensure!(
        !bak.exists(),
        "refusing to overwrite existing archive {}; \
         move it aside manually and retry",
        bak.display()
    );
    fs::rename(path, &bak)
        .with_context(|| format!("failed to archive {} -> {}", path.display(), bak.display()))?;
    Ok(bak)
}

/// Install the staged temp file `tmp` at `target`, archiving any existing
/// `target` first. Shared commit primitive for the staged-key types
/// ([`StagedNodeKey`] and `decdn_incentive`'s `StagedKeystore`) so their
/// archive → rename → fail-safe-rollback logic stays in one place.
///
/// **Fail-safe.** If the install rename fails after the prior file was archived,
/// the archive is restored (best-effort) so `target` keeps the prior file rather
/// than going missing. The returned error distinguishes the two outcomes:
/// - restore succeeded → the prior file is live at `target`, safe to retry;
/// - restore also failed → `target` is now **missing**, and the error names the
///   surviving `.bak` archive the operator must move back manually.
///
/// Does **not** remove `tmp` on failure — the caller's `Drop` owns temp cleanup,
/// so the temp is reclaimed whether the caller `?`-propagates or not.
///
/// Returns the archive path if a prior file was moved aside, or `None` (fresh
/// install).
///
/// # Errors
///
/// Returns an error if archiving the prior file fails, or if the install rename
/// fails (with the best-effort restore outcome folded into the message).
pub fn install_staged(tmp: &Path, target: &Path) -> anyhow::Result<Option<PathBuf>> {
    let bak = if target.exists() {
        Some(move_aside(target)?)
    } else {
        None
    };
    let Err(rename_err) = fs::rename(tmp, target) else {
        return Ok(bak);
    };
    let base = format!("failed to rename {} -> {}", tmp.display(), target.display());
    // The install rename failed *after* any prior file was archived, which would
    // otherwise leave `target` missing. Restore the archive (best-effort) so a
    // failed rotation is fail-safe. The caller's `Drop` reclaims `tmp`.
    let Some(bak_path) = &bak else {
        // Fresh install: nothing was archived, so nothing to restore — `target`
        // was already absent and stays absent. Clean failure.
        return Err(anyhow::Error::new(rename_err).context(base));
    };
    match fs::rename(bak_path, target) {
        Ok(()) => Err(anyhow::Error::new(rename_err).context(format!(
            "{base}; the prior file was restored to {} and stays live (safe to retry)",
            target.display()
        ))),
        Err(restore_err) => Err(anyhow::Error::new(rename_err).context(format!(
            "{base}, AND restoring the archived prior file failed ({restore_err}): {} is now \
             MISSING — its only surviving copy is the archive at {}. Move it back manually before \
             starting the node (appendix-operator-key-rotation.md §5 rollback)",
            target.display(),
            bak_path.display()
        ))),
    }
}

/// A freshly-generated node key written to a temp file in `data_dir`, not yet
/// committed to `node.secret`.
///
/// Staging (generate + write the temp) is separated from committing
/// (archive-old + rename) so a multi-file rotation can generate **all** key
/// material before touching any canonical file — a failure while generating the
/// *second* secret then leaves the first untouched rather than half-rotated
/// (#844). Produced by [`stage_node_key`]; finished with [`Self::commit`].
///
/// Dropping a [`StagedNodeKey`] without committing removes the temp file, so an
/// abandoned stage never litters `data_dir`.
#[must_use = "a staged node key must be committed (or it is discarded on drop)"]
pub struct StagedNodeKey {
    tmp: PathBuf,
    final_path: PathBuf,
    key: SecretKey,
    committed: bool,
}

impl std::fmt::Debug for StagedNodeKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never format the secret key.
        f.debug_struct("StagedNodeKey")
            .field("final_path", &self.final_path)
            .field("committed", &self.committed)
            .finish_non_exhaustive()
    }
}

impl StagedNodeKey {
    /// The public key of the staged secret, for logging the node id before the
    /// commit lands.
    #[must_use]
    pub fn public(&self) -> PublicKey {
        self.key.public()
    }

    /// Sign `msg` with the staged secret, without committing it.
    ///
    /// `decdn node rotate-key` needs this: `CapacityBond.bindNodeId` demands an
    /// ed25519 ownership proof from the key being bound, and that proof has to
    /// exist *before* the transaction is submitted — while committing
    /// `node.secret` has to happen *after* it confirms, or a reverted bind
    /// would leave the daemon serving under an unbound identity. Signing from
    /// the stage is what lets those two orders coexist.
    ///
    /// The secret itself stays unreachable: this is deliberately a signing
    /// oracle rather than a `secret()` accessor, so a staged key cannot be
    /// copied out and persisted somewhere the archive-on-commit discipline
    /// does not cover.
    #[must_use]
    pub fn sign(&self, msg: &[u8]) -> Signature {
        self.key.sign(msg)
    }

    /// Commit the staged key: archive any existing `node.secret` (so the
    /// operator key-rotation runbook keeps the prior material, mirroring the
    /// no-stage path) and atomically rename the temp into place.
    ///
    /// Returns the archive path if a prior key was moved aside, or `None` when
    /// there was nothing to archive (fresh install).
    ///
    /// Takes `&mut self` rather than `self` **so a failed commit does not
    /// destroy the key**. Consuming `self` meant the `?` below dropped the
    /// stage with `committed == false`, and `Drop` deleted the temp — on a
    /// caller path (`decdn node rotate-key`) that only reaches this call after
    /// proving on-chain that the binding moved to this key. The caller must
    /// stay able to [`Self::keep`] it instead. The cost is that a caller can
    /// now observe a committed stage; `commit` is idempotent-safe in that a
    /// second call would simply fail on the already-renamed temp.
    ///
    /// # Errors
    ///
    /// Returns an error if archiving the prior key or the install rename fails.
    /// The commit is **fail-safe** via [`install_staged`]: if the install rename
    /// fails after the prior key was archived, the archive is restored
    /// (best-effort) so the old key stays live, and the error states whether the
    /// restore succeeded or `node.secret` is now missing. On error `committed`
    /// stays `false`, so the caller may still [`Self::keep`] the staged key —
    /// and if it does not, `Drop` reclaims the temp.
    pub fn commit(&mut self) -> anyhow::Result<Option<PathBuf>> {
        let bak = install_staged(&self.tmp, &self.final_path)?;
        self.committed = true;
        Ok(bak)
    }

    /// Preserve the staged key *without* installing it, at
    /// `<data_dir>/node.secret.pending.<node_id>`. Returns the path it landed at.
    ///
    /// The third outcome, between [`Self::commit`] and the `Drop` that discards
    /// an abandoned stage. It exists for one situation, reached three ways: a
    /// key-rotation transaction may have taken effect, but the caller cannot
    /// confirm the binding moved — the receipt could not be read at all, or it
    /// mined carrying no matching `NodeIdBound`, or it mined and the install of
    /// the confirmed key then failed. Whether the chain now names this key is
    /// **unknown** in the first two, and *known* in the third — where the key is
    /// even more valuable. Discarding the secret there is unrecoverable — if the
    /// transaction did land, the operator is bound to an identity whose private
    /// key no longer exists anywhere, and the daemon keeps serving under a key
    /// nothing binds. Installing it is equally wrong, because the transaction
    /// may instead have failed.
    ///
    /// So the key is parked under a name that is deliberately *not*
    /// `node.secret`: no daemon will load it (`load_or_create` reads exactly
    /// `<data_dir>/node.secret`), and the operator can install it once they have
    /// checked whether the transaction confirmed.
    ///
    /// # Errors
    ///
    /// Returns an error if the destination has no parent directory, or if the
    /// rename fails. In **both** cases the staged temp is deliberately left in
    /// place and its path is named in the error, rather than being reclaimed by
    /// `Drop`: this is only ever called when the key may already be bound
    /// on-chain, so an awkwardly-named surviving file beats an unrecoverable
    /// identity. The caller is expected to print that path.
    pub fn keep(mut self) -> anyhow::Result<PathBuf> {
        // Set FIRST, not after the rename: every exit from here on must leave
        // the temp on disk. The success path renames it (so `Drop` must not
        // chase the old path), and both failure paths need it preserved for the
        // reason in the doc above. This is the inverse of `commit`, where a
        // failure means the key is genuinely worthless.
        self.committed = true;
        let parent = self.final_path.parent().ok_or_else(|| {
            anyhow!(
                "staged key path has no parent directory: {}; the staged key is at {}",
                self.final_path.display(),
                self.tmp.display()
            )
        })?;
        let dest = parent.join(format!("{KEY_FILE_NAME}.pending.{}", self.key.public()));
        fs::rename(&self.tmp, &dest).with_context(|| {
            format!(
                "failed to park the staged node key at {}; it has been LEFT IN PLACE at {} — \
                 move it there yourself before starting the node",
                dest.display(),
                self.tmp.display()
            )
        })?;
        Ok(dest)
    }
}

impl Drop for StagedNodeKey {
    fn drop(&mut self) {
        if !self.committed {
            // Best-effort: an abandoned stage must not leave key material behind.
            let _ = fs::remove_file(&self.tmp);
        }
    }
}

/// Generate a fresh node key and write it to a temp file in `data_dir` (mode
/// `0o600` on Unix) **without** touching `node.secret`. Validates or securely
/// creates `data_dir` with the same `0o700` semantics as [`load_or_create`].
///
/// The returned [`StagedNodeKey`] is committed via [`StagedNodeKey::commit`].
/// This is the staged counterpart of [`load_or_create`]'s generate path, used
/// by `decdn key-gen` so the node key and the eth keystore are both generated
/// before either canonical file is replaced (#844).
///
/// # Errors
///
/// Returns an error if `data_dir` cannot be validated or created, or if writing
/// the temp file fails.
pub fn stage_node_key(data_dir: &Path) -> anyhow::Result<StagedNodeKey> {
    ensure_data_dir(data_dir)?;
    let final_path = key_path(data_dir);
    let key = fresh_secret_key();
    let tmp = write_temp(&final_path, &key.to_bytes())?;
    Ok(StagedNodeKey {
        tmp,
        final_path,
        key,
        committed: false,
    })
}

/// Reject `data_dir` if it isn't a directory or if any group/other permission
/// bit is set.
///
/// Uses `symlink_metadata` so symlinks at `data_dir` are rejected outright.
/// `metadata()` would follow the link and evaluate the target's mode, which
/// leaves a race where the symlink can be repointed between validation and
/// use — exercised by `rejects_symlinked_data_dir`.
#[cfg(unix)]
pub(crate) fn validate_data_dir(data_dir: &Path) -> anyhow::Result<()> {
    use std::os::unix::fs::MetadataExt;
    let meta = fs::symlink_metadata(data_dir)
        .with_context(|| format!("invalid data_dir: cannot access {}", data_dir.display()))?;
    anyhow::ensure!(
        meta.file_type().is_dir(),
        "invalid data_dir: {} is not a directory",
        data_dir.display()
    );
    let mode = meta.mode() & 0o777;
    anyhow::ensure!(
        mode & FORBIDDEN_BITS == 0,
        "invalid data_dir: {} has insecure permissions {:#o} \
         (group/other bits must be clear; expected {:#o})",
        data_dir.display(),
        mode,
        DATA_DIR_MODE
    );
    Ok(())
}

/// Reject the key file if it isn't a regular file or if any group/other
/// permission bit is set.
///
/// There is a small TOCTOU window between this check and `load_from`'s
/// `fs::read`, but exploiting it requires write access to `data_dir` — which
/// `validate_data_dir` already denies, so the two checks compose. Tightening
/// to an `O_NOFOLLOW` open would require a libc dep; deferred.
#[cfg(unix)]
fn validate_key_file(path: &Path) -> anyhow::Result<()> {
    use std::os::unix::fs::MetadataExt;
    let meta = fs::symlink_metadata(path)
        .with_context(|| format!("invalid node.secret: cannot access {}", path.display()))?;
    anyhow::ensure!(
        meta.file_type().is_file(),
        "invalid node.secret: {} is not a regular file",
        path.display()
    );
    let mode = meta.mode() & 0o777;
    anyhow::ensure!(
        mode & FORBIDDEN_BITS == 0,
        "invalid node.secret: {} has insecure permissions {:#o} \
         (must be user-only, e.g. {:#o})",
        path.display(),
        mode,
        0o600
    );
    Ok(())
}

/// Create `data_dir` (and any missing parents) with mode `0o700` on the leaf
/// created by this call. `DirBuilder::mode` only applies to directories this
/// call creates — pre-existing parents like `~/.local/share` keep their own
/// permissions.
#[cfg(unix)]
pub(crate) fn create_data_dir_secure(data_dir: &Path) -> anyhow::Result<()> {
    use std::os::unix::fs::DirBuilderExt;
    fs::DirBuilder::new()
        .recursive(true)
        .mode(DATA_DIR_MODE)
        .create(data_dir)
        .with_context(|| format!("failed to create data dir {}", data_dir.display()))
}

#[cfg(not(unix))]
#[allow(
    clippy::unnecessary_wraps,
    reason = "the Unix variant can fail; the shared call sites take a Result"
)]
#[allow(
    clippy::missing_const_for_fn,
    reason = "the Unix variant does filesystem I/O and cannot be const; the shared call sites are not const contexts"
)]
pub(crate) fn validate_data_dir(_data_dir: &Path) -> anyhow::Result<()> {
    Ok(())
}

#[cfg(not(unix))]
#[allow(
    clippy::unnecessary_wraps,
    reason = "the Unix variant can fail; the shared call sites take a Result"
)]
#[allow(
    clippy::missing_const_for_fn,
    reason = "the Unix variant does filesystem I/O and cannot be const; the shared call sites are not const contexts"
)]
fn validate_key_file(_path: &Path) -> anyhow::Result<()> {
    Ok(())
}

#[cfg(not(unix))]
pub(crate) fn create_data_dir_secure(data_dir: &Path) -> anyhow::Result<()> {
    fs::create_dir_all(data_dir)
        .with_context(|| format!("failed to create data dir {}", data_dir.display()))
}

/// Load the node's `SecretKey` from an existing file.
///
/// Callers must validate `path` via [`validate_key_file`] first — this
/// function follows symlinks via `fs::read`.
///
/// # Errors
///
/// Returns an error if the file cannot be read or is not exactly 32 bytes.
fn load_from(path: &Path) -> anyhow::Result<SecretKey> {
    let bytes = fs::read(path)
        .with_context(|| format!("failed to read secret key file {}", path.display()))?;
    let len = bytes.len();
    let arr: [u8; KEY_LEN] = bytes.as_slice().try_into().map_err(|_| {
        anyhow!(
            "secret key file {} must be {KEY_LEN} bytes, got {len}",
            path.display()
        )
    })?;
    Ok(SecretKey::from_bytes(&arr))
}

/// A newly generated node secret key, drawn from the OS RNG. The caller owns
/// persisting it; nothing here touches disk.
pub fn fresh_secret_key() -> SecretKey {
    SecretKey::generate()
}

/// Write `bytes` to `path` atomically. On Unix the temp file is created with
/// mode 0600 from the start (via `OpenOptionsExt::mode`) so the key material is
/// never briefly exposed under a permissive umask.
fn write_atomic(path: &Path, bytes: &[u8; KEY_LEN]) -> anyhow::Result<()> {
    let tmp = write_temp(path, bytes)?;
    if let Err(e) = fs::rename(&tmp, path)
        .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
    {
        // The rename never happened, so the staged temp is still on disk —
        // remove it so a partial write doesn't accumulate in `data_dir`.
        let _ = fs::remove_file(&tmp);
        return Err(e);
    }
    Ok(())
}

/// Write `bytes` to a freshly-created `<path>.tmp.<suffix>` sibling (mode 0600
/// on Unix from creation, then fsync'd) and return that temp path **without**
/// renaming it into place. The caller commits by renaming `tmp` → `path`, or
/// drops it by removing the file. This is the staging half of [`write_atomic`],
/// shared with [`stage_node_key`] so a multi-file rotation can generate all key
/// material before touching any canonical file (#844).
///
/// On any error the temp file is cleaned up before returning.
fn write_temp(path: &Path, bytes: &[u8; KEY_LEN]) -> anyhow::Result<PathBuf> {
    let parent = path
        .parent()
        .ok_or_else(|| anyhow!("key path {} has no parent directory", path.display()))?;
    // Unique temp name per writer: pid + 8 random hex chars. Avoids the race
    // where two concurrent writers clobber each other's temp file. The rename
    // step (in the caller) is atomic on Unix, so only one final `path` will exist.
    let suffix = {
        let mut s = [0u8; 4];
        rand::rng().fill_bytes(&mut s);
        format!(
            "{:08x}{:02x}{:02x}{:02x}{:02x}",
            std::process::id(),
            s[0],
            s[1],
            s[2],
            s[3],
        )
    };
    let tmp = parent.join(format!(
        "{}.tmp.{suffix}",
        path.file_name()
            .and_then(|s| s.to_str())
            .unwrap_or(KEY_FILE_NAME),
    ));

    // Closure lets us funnel every fallible step through a single cleanup
    // path: on any error after `create_new`, remove the tmp file so partial
    // writes don't accumulate in `data_dir`. Cleanup failures are ignored —
    // the real error is already in flight.
    let result: anyhow::Result<()> = (|| {
        #[cfg(unix)]
        {
            use std::io::Write;
            use std::os::unix::fs::OpenOptionsExt;
            let mut file = fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .mode(0o600)
                .open(&tmp)
                .with_context(|| format!("failed to create {}", tmp.display()))?;
            file.write_all(bytes)
                .with_context(|| format!("failed to write {}", tmp.display()))?;
            file.sync_all()
                .with_context(|| format!("failed to sync {}", tmp.display()))?;
        }

        #[cfg(not(unix))]
        {
            fs::write(&tmp, bytes).with_context(|| format!("failed to write {}", tmp.display()))?;
        }

        Ok(())
    })();

    match result {
        Ok(()) => Ok(tmp),
        Err(e) => {
            let _ = fs::remove_file(&tmp);
            Err(e)
        }
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing
)]
mod tests;