amaters-server 0.2.1

AmateRS server binary
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
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
//! Hot-reload support for server configuration and TLS certificates.
//!
//! # Config reload (SIGHUP)
//!
//! SIGHUP handling is provided by [`crate::shutdown::setup_sighup_handler`]
//! together with [`crate::config::ReloadableConfig`], which already implement
//! the full SIGHUP → re-parse → atomic swap → log-diff pipeline.
//!
//! [`spawn_config_reloader`] is a thin façade that wires those existing
//! building blocks together and returns a `JoinHandle` so callers can
//! cancel the background task when needed.
//!
//! # TLS certificate reload
//!
//! [`spawn_tls_reloader`] watches a directory for changes to cert/key files
//! using [`notify`].  On a detected change it reloads the PEM bytes and
//! atomically swaps them into an [`arc_swap::ArcSwap<TlsCreds>`].
//!
//! **Integration note:** tonic's `ServerTlsConfig` is consumed by
//! `tonic::transport::Server::builder().tls_config(...)` at server-start time.
//! To use live-rotated credentials the server must be built with a custom
//! `rustls::ServerConfig` derived from the shared [`TlsCreds`] store, so that
//! each new TLS handshake uses the latest certificate.  The
//! [`spawn_tls_reloader`] API intentionally exposes the raw [`TlsCreds`] store
//! so the caller can build that custom acceptor; helper
//! [`build_server_tls_config`] converts the current credentials into a
//! `tonic::transport::ServerTlsConfig` for use at startup or reconnect.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use arc_swap::ArcSwap;
use notify::{Event, RecursiveMode, Watcher};
use parking_lot::RwLock;
use thiserror::Error;
use tokio::task::JoinHandle;
use tonic::transport::{Identity, ServerTlsConfig};
use tracing::{error, info, warn};

use amaters_net::tls_acceptor::{TlsCredsRef, build_rustls_config};

use crate::config::{ConfigError, ReloadableConfig, ServerConfig};

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Errors that can occur during hot-reload operations.
#[derive(Debug, Error)]
pub enum HotReloadError {
    /// File-system watcher could not be created or watch path could not be added.
    #[error("File watcher error: {0}")]
    Watch(#[from] notify::Error),

    /// An I/O error occurred while reading a certificate or key file.
    #[error("IO error reading TLS file: {0}")]
    Io(#[from] std::io::Error),

    /// The PEM bytes loaded from disk could not be used to build a TLS identity.
    #[error("TLS credential error: {0}")]
    Tls(String),

    /// A `rustls::ServerConfig` could not be built from the provided creds —
    /// surfaced from `amaters_net::tls_acceptor::build_rustls_config`.
    #[error("rustls config error: {0}")]
    Rustls(String),

    /// The config reload itself reported an error.
    #[error("Config error: {0}")]
    Config(#[from] ConfigError),
}

// ---------------------------------------------------------------------------
// Raw TLS credentials (cert + key PEM bytes)
// ---------------------------------------------------------------------------

/// Raw PEM bytes for a TLS certificate and private key.
///
/// Stored inside an [`ArcSwap`] so that new credentials can be swapped in
/// atomically without stopping the server.  Any code that builds a
/// `rustls::ServerConfig` or `tonic::transport::ServerTlsConfig` should load
/// the current value from the `ArcSwap` immediately before use so it picks up
/// any rotation that happened since the last call.
#[derive(Clone, Debug)]
pub struct TlsCreds {
    /// PEM-encoded certificate chain.
    pub cert_pem: Vec<u8>,
    /// PEM-encoded private key.
    pub key_pem: Vec<u8>,
}

impl TlsCreds {
    /// Load `TlsCreds` from cert and key files on disk.
    pub fn load_from_files(cert_path: &Path, key_path: &Path) -> Result<Self, HotReloadError> {
        let cert_pem = std::fs::read(cert_path)?;
        let key_pem = std::fs::read(key_path)?;
        Ok(Self { cert_pem, key_pem })
    }

    /// Convert these credentials into a `tonic` [`ServerTlsConfig`].
    pub fn to_server_tls_config(&self) -> ServerTlsConfig {
        let identity = Identity::from_pem(&self.cert_pem, &self.key_pem);
        ServerTlsConfig::new().identity(identity)
    }
}

// ---------------------------------------------------------------------------
// Build helpers
// ---------------------------------------------------------------------------

/// Load cert + key from disk and construct a `tonic` [`ServerTlsConfig`].
///
/// This is equivalent to calling `TlsCreds::load_from_files` followed by
/// `TlsCreds::to_server_tls_config`, provided as a convenience.
pub fn build_server_tls_config(
    cert_path: &Path,
    key_path: &Path,
) -> Result<ServerTlsConfig, HotReloadError> {
    Ok(TlsCreds::load_from_files(cert_path, key_path)?.to_server_tls_config())
}

// ---------------------------------------------------------------------------
// Item 10: SIGHUP config reloader façade
// ---------------------------------------------------------------------------

/// Spawn a background task that reloads `config` on `SIGHUP`.
///
/// This is a thin façade over the existing [`crate::shutdown::setup_sighup_handler`]
/// infrastructure.  It is provided so callers have a single, consistent API
/// that returns a [`JoinHandle`] they can cancel when the server shuts down.
///
/// On non-Unix platforms the spawned task logs a warning and exits immediately.
///
/// # Config reload semantics
///
/// When `SIGHUP` is received:
/// 1. The config file at `config_path` is re-parsed.
/// 2. The new config is validated.
/// 3. Only *reloadable* sections (logging, metrics, compaction, rate-limits) are
///    applied atomically via `RwLock::write`.
/// 4. Non-reloadable sections (bind address, storage engine, TLS cert *path*, …)
///    are logged as skipped.
/// 5. If validation fails, the old config is preserved and an error is logged.
///
/// See [`crate::config::ReloadableSection`] and [`crate::config::ConfigDiff`] for details.
pub async fn spawn_config_reloader(
    config_path: PathBuf,
    config: Arc<RwLock<ServerConfig>>,
) -> JoinHandle<()> {
    // Build a ReloadableConfig backed by the same lock so that any writes made
    // through setup_sighup_handler are visible to code that holds a reference
    // to `config` directly.
    //
    // ReloadableConfig wraps Arc<RwLock<ServerConfig>> internally.  We create
    // one from the provided config, then hand it to setup_sighup_handler.
    let initial = config.read().clone();
    let reloadable = ReloadableConfig::new(initial);
    reloadable.set_config_path(config_path.clone());

    // Clone the reloadable so we can move it into the task below.
    let reloadable_for_task = reloadable.clone();
    let config_for_task = config.clone();

    tokio::spawn(async move {
        #[cfg(unix)]
        {
            use tokio::signal::unix::{SignalKind, signal};

            let mut hangup = match signal(SignalKind::hangup()) {
                Ok(s) => s,
                Err(e) => {
                    warn!("Failed to register SIGHUP signal handler: {}", e);
                    return;
                }
            };

            loop {
                hangup.recv().await;
                info!("SIGHUP received — reloading config from {:?}", config_path);

                // Reload through ReloadableConfig (validates + section-aware swap).
                match reloadable_for_task.reload_from_stored_path() {
                    Ok(report) if report.success => {
                        // Mirror the updated reloadable snapshot back to the raw lock
                        // so callers holding `Arc<RwLock<ServerConfig>>` see the change.
                        let updated = reloadable_for_task.snapshot();
                        *config_for_task.write() = updated;
                        info!("Config reloaded successfully: {}", report);
                    }
                    Ok(report) => {
                        error!("Config reload failed — keeping old config: {}", report);
                    }
                    Err(e) => {
                        error!("Config reload error — keeping old config: {}", e);
                    }
                }
            }
        }

        #[cfg(not(unix))]
        {
            warn!(
                "SIGHUP config reload is only supported on Unix platforms. \
                 Use ReloadableConfig::manual_reload() as an alternative."
            );
        }
    })
}

// ---------------------------------------------------------------------------
// Item 11: TLS certificate file watcher
// ---------------------------------------------------------------------------

/// Spawn a background task that watches TLS cert and key files for changes.
///
/// When a change is detected in the directory containing `cert_path`, the
/// cert and key are reloaded from disk and the new [`TlsCreds`] is atomically
/// stored via [`ArcSwap::store`].
///
/// New connections that read from the [`ArcSwap`] after the swap will use the
/// new credentials; existing connections complete with whatever credentials
/// they negotiated at handshake time.
///
/// Returns an error if the file-system watcher cannot be initialised.
///
/// # Integration
///
/// ```rust,ignore
/// let creds = Arc::new(ArcSwap::from_pointee(
///     TlsCreds::load_from_files(&cert_path, &key_path)?,
/// ));
///
/// spawn_tls_reloader(cert_path, key_path, Arc::clone(&creds)).await?;
///
/// // Build initial tls config from creds for tonic:
/// let tls_config = creds.load().to_server_tls_config();
/// ```
pub async fn spawn_tls_reloader(
    cert_path: PathBuf,
    key_path: PathBuf,
    tls_creds: Arc<ArcSwap<TlsCreds>>,
) -> Result<(), HotReloadError> {
    // notify v8 uses a sync channel; we bridge it to an async mpsc so the
    // spawned task can use .await without blocking the tokio thread.
    let (tx, mut rx) = tokio::sync::mpsc::channel::<notify::Result<Event>>(16);

    let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
        // best-effort: if the channel is full or closed, silently drop the event.
        let _ = tx.blocking_send(event);
    })?;

    // Watch the directory that contains the cert file (non-recursive).
    let cert_dir = cert_path.parent().unwrap_or_else(|| Path::new("."));
    watcher.watch(cert_dir, RecursiveMode::NonRecursive)?;

    // Clone paths for use inside the spawned task.
    let cert_path_task = cert_path.clone();
    let key_path_task = key_path.clone();

    tokio::spawn(async move {
        // Keep `watcher` alive inside the task.
        let _watcher = watcher;

        while let Some(event) = rx.recv().await {
            match event {
                Ok(e) => {
                    // Only reload on events that touch the cert or key file.
                    let relevant = e
                        .paths
                        .iter()
                        .any(|p| p == &cert_path_task || p == &key_path_task);

                    if !relevant {
                        continue;
                    }

                    match TlsCreds::load_from_files(&cert_path_task, &key_path_task) {
                        Ok(new_creds) => {
                            tls_creds.store(Arc::new(new_creds));
                            info!("TLS credentials reloaded from {:?}", cert_path_task);
                        }
                        Err(e) => {
                            error!("TLS reload failed — keeping existing credentials: {}", e);
                        }
                    }
                }
                Err(e) => {
                    warn!("File-watcher error (TLS reloader): {}", e);
                }
            }
        }
    });

    Ok(())
}

// ---------------------------------------------------------------------------
// Live rustls config rotation (Item 2)
// ---------------------------------------------------------------------------

/// Atomically swap the active `rustls::ServerConfig` in `store` with a new one
/// derived from `creds`.
///
/// Used by [`spawn_tls_reloader_with_rustls_store`] on each detected file
/// change.  Callers may also invoke this manually to rotate without involving
/// the watcher (e.g. operator-driven rotation via an admin RPC).
///
/// # Errors
///
/// Returns [`HotReloadError::Rustls`] if the new credentials cannot be parsed
/// into a valid `ServerConfig` (invalid PEM, mismatched key, …).  In that case
/// the old config is left in place and the caller can decide whether to retry.
pub fn swap_rustls_config(
    store: &Arc<ArcSwap<rustls::ServerConfig>>,
    creds: &TlsCreds,
) -> Result<(), HotReloadError> {
    let creds_ref = TlsCredsRef::new(&creds.cert_pem, &creds.key_pem);
    let new_config =
        build_rustls_config(&creds_ref).map_err(|e| HotReloadError::Rustls(e.to_string()))?;
    store.store(Arc::new(new_config));
    Ok(())
}

/// Spawn a TLS file-watcher that updates **both** the legacy [`TlsCreds`]
/// store and a [`rustls::ServerConfig`] store.
///
/// The dual-store design preserves backward compatibility with code paths
/// that still consume `TlsCreds` (e.g. for `tonic::transport::ServerTlsConfig`)
/// while wiring the live-rotating
/// [`amaters_net::tls_acceptor::LiveTlsAcceptor`] to the rustls store.
///
/// # Behaviour
///
/// On each detected change:
/// 1. Reload PEM bytes into a new [`TlsCreds`].
/// 2. Build a fresh `rustls::ServerConfig` via
///    [`amaters_net::tls_acceptor::build_rustls_config`].
/// 3. Atomically swap **both** stores.
///
/// If step 2 fails (invalid PEM), neither store is updated and an error is
/// logged; the old config keeps serving traffic.
///
/// # Errors
///
/// Returns an error if the file-system watcher cannot be initialised.
pub async fn spawn_tls_reloader_with_rustls_store(
    cert_path: PathBuf,
    key_path: PathBuf,
    tls_creds: Arc<ArcSwap<TlsCreds>>,
    rustls_store: Arc<ArcSwap<rustls::ServerConfig>>,
) -> Result<(), HotReloadError> {
    // Mirror the non-rustls watcher's plumbing.
    let (tx, mut rx) = tokio::sync::mpsc::channel::<notify::Result<Event>>(16);

    let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
        let _ = tx.blocking_send(event);
    })?;

    let cert_dir = cert_path.parent().unwrap_or_else(|| Path::new("."));
    watcher.watch(cert_dir, RecursiveMode::NonRecursive)?;

    let cert_path_task = cert_path.clone();
    let key_path_task = key_path.clone();

    tokio::spawn(async move {
        let _watcher = watcher;

        while let Some(event) = rx.recv().await {
            match event {
                Ok(e) => {
                    let relevant = e
                        .paths
                        .iter()
                        .any(|p| p == &cert_path_task || p == &key_path_task);
                    if !relevant {
                        continue;
                    }

                    let new_creds = match TlsCreds::load_from_files(&cert_path_task, &key_path_task)
                    {
                        Ok(c) => c,
                        Err(e) => {
                            error!(
                                "TLS reload failed (file read) — keeping existing credentials: {e}",
                            );
                            continue;
                        }
                    };

                    // Build the new rustls config first; if it fails, neither store
                    // is updated.
                    if let Err(e) = swap_rustls_config(&rustls_store, &new_creds) {
                        error!("TLS reload failed (rustls build) — keeping existing config: {e}",);
                        continue;
                    }

                    tls_creds.store(Arc::new(new_creds));
                    info!(
                        "TLS credentials reloaded (legacy + rustls) from {:?}",
                        cert_path_task
                    );
                }
                Err(e) => {
                    warn!("File-watcher error (TLS reloader): {e}");
                }
            }
        }
    });

    Ok(())
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::env;
    use std::fs;

    // -----------------------------------------------------------------------
    // Helper: build a minimal ServerConfig with a given bind address
    // -----------------------------------------------------------------------
    fn make_config(bind: &str) -> ServerConfig {
        let mut c = ServerConfig::default();
        c.server.bind_address = bind.to_string();
        c
    }

    // -----------------------------------------------------------------------
    // config_diff tests (via ReloadableConfig)
    // -----------------------------------------------------------------------

    /// Reloading with an identical config produces no section updates.
    #[test]
    fn test_config_diff_empty_when_identical() {
        use crate::config::diff;
        let c = make_config("127.0.0.1:7878");
        let d = diff(&c, &c);
        assert!(
            d.is_empty(),
            "Diff of identical configs should be empty, got {:?}",
            d
        );
    }

    /// Reloading with a changed log level marks the Logging section as changed.
    #[test]
    fn test_config_diff_detects_log_level_change() {
        use crate::config::ReloadableSection;
        use crate::config::diff;
        let old = make_config("127.0.0.1:7878");
        let mut new = old.clone();
        new.logging.level = "debug".to_string();
        let d = diff(&old, &new);
        assert!(
            d.reloadable_changes.contains(&ReloadableSection::Logging),
            "Expected Logging in reloadable_changes, got {:?}",
            d.reloadable_changes
        );
    }

    /// Changing max_connections marks the RateLimit section as changed.
    #[test]
    fn test_config_diff_detects_rate_limit_change() {
        use crate::config::ReloadableSection;
        use crate::config::diff;
        let old = make_config("127.0.0.1:7878");
        let mut new = old.clone();
        new.server.max_connections = old.server.max_connections + 500;
        let d = diff(&old, &new);
        assert!(
            d.reloadable_changes.contains(&ReloadableSection::RateLimit),
            "Expected RateLimit in reloadable_changes, got {:?}",
            d.reloadable_changes
        );
    }

    /// Changing bind_address marks it as non-reloadable (requires restart).
    #[test]
    fn test_config_diff_non_reloadable_bind_address() {
        use crate::config::{NonReloadableSection, diff};
        let old = make_config("127.0.0.1:7878");
        let new = make_config("127.0.0.1:9999");
        let d = diff(&old, &new);
        assert!(
            d.non_reloadable_changes
                .contains(&NonReloadableSection::BindAddress),
            "Expected BindAddress in non_reloadable_changes, got {:?}",
            d.non_reloadable_changes
        );
    }

    // -----------------------------------------------------------------------
    // ReloadableConfig + manual_reload round-trip
    // -----------------------------------------------------------------------

    /// Writing an updated config to disk and calling manual_reload applies
    /// only reloadable changes.
    #[test]
    fn test_manual_reload_applies_log_level_change() {
        let dir = env::temp_dir();
        let path = dir.join("amaters_hot_reload_test_manual.toml");

        // Write initial config.
        let initial = make_config("127.0.0.1:7878");
        initial.save_to_file(&path).expect("save initial config");

        let rc = ReloadableConfig::new(initial.clone());
        rc.set_config_path(path.clone());

        // Modify log level in file.
        let mut updated = initial.clone();
        updated.logging.level = "warn".to_string();
        updated.save_to_file(&path).expect("save updated config");

        let report = rc.manual_reload().expect("manual_reload succeeded");
        assert!(report.success, "Expected reload success: {:?}", report);

        // Verify the live config has the new log level.
        assert_eq!(
            rc.snapshot().logging.level,
            "warn",
            "Log level should be updated to 'warn'"
        );

        fs::remove_file(&path).ok();
    }

    // -----------------------------------------------------------------------
    // TlsCreds helpers
    // -----------------------------------------------------------------------

    /// load_from_files returns Io error for missing files.
    #[test]
    fn test_tls_creds_load_missing_file() {
        let result = TlsCreds::load_from_files(
            Path::new("/nonexistent/cert.pem"),
            Path::new("/nonexistent/key.pem"),
        );
        assert!(result.is_err(), "Expected error for missing files");
    }

    /// load_from_files succeeds when both files exist.
    #[test]
    fn test_tls_creds_load_valid_files() {
        let dir = env::temp_dir();
        let cert = dir.join("amaters_hot_reload_test_cert.pem");
        let key = dir.join("amaters_hot_reload_test_key.pem");

        fs::write(
            &cert,
            b"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n",
        )
        .expect("write cert");
        fs::write(
            &key,
            b"-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n",
        )
        .expect("write key");

        let creds = TlsCreds::load_from_files(&cert, &key).expect("load creds");
        assert!(!creds.cert_pem.is_empty());
        assert!(!creds.key_pem.is_empty());

        fs::remove_file(&cert).ok();
        fs::remove_file(&key).ok();
    }

    /// ArcSwap correctly holds and swaps TlsCreds.
    #[test]
    fn test_tls_creds_arc_swap() {
        let dir = env::temp_dir();
        let cert = dir.join("amaters_arc_swap_cert.pem");
        let key = dir.join("amaters_arc_swap_key.pem");

        fs::write(&cert, b"cert_v1").expect("write cert");
        fs::write(&key, b"key_v1").expect("write key");

        let creds1 = TlsCreds::load_from_files(&cert, &key).expect("load v1");
        let store: Arc<ArcSwap<TlsCreds>> = Arc::new(ArcSwap::from_pointee(creds1));

        assert_eq!(store.load().cert_pem, b"cert_v1");

        // Swap in new creds.
        fs::write(&cert, b"cert_v2").expect("write cert v2");
        fs::write(&key, b"key_v2").expect("write key v2");
        let creds2 = TlsCreds::load_from_files(&cert, &key).expect("load v2");
        store.store(Arc::new(creds2));

        assert_eq!(store.load().cert_pem, b"cert_v2");

        fs::remove_file(&cert).ok();
        fs::remove_file(&key).ok();
    }

    // -----------------------------------------------------------------------
    // build_server_tls_config helper
    // -----------------------------------------------------------------------

    /// build_server_tls_config succeeds when cert+key files exist.
    /// Note: tonic will reject non-valid PEM at handshake time, not at build
    /// time, so this test verifies the function does not return a file-IO error.
    #[test]
    fn test_build_server_tls_config_file_error() {
        let result = build_server_tls_config(
            Path::new("/nonexistent/cert.pem"),
            Path::new("/nonexistent/key.pem"),
        );
        assert!(
            matches!(result, Err(HotReloadError::Io(_))),
            "Expected Io error, got {:?}",
            result
        );
    }

    /// `swap_rustls_config` rejects garbage creds with a `Rustls` error rather
    /// than panicking.
    #[test]
    fn test_swap_rustls_config_rejects_invalid_pem() {
        // Build an initial valid config from a tempfile so we have something
        // in the store to begin with.
        let dir = env::temp_dir();
        let cert = dir.join(format!(
            "amaters_swap_rustls_cert_{}.pem",
            uuid::Uuid::new_v4()
        ));
        let key = dir.join(format!(
            "amaters_swap_rustls_key_{}.pem",
            uuid::Uuid::new_v4()
        ));
        // We cannot easily generate a real PEM here without rcgen; instead
        // start with bytes that build_rustls_config will reject and verify
        // the error variant.
        fs::write(&cert, b"not-pem").expect("write cert");
        fs::write(&key, b"not-pem").expect("write key");

        let creds = TlsCreds::load_from_files(&cert, &key).expect("load creds");
        // Seed the store with a placeholder ServerConfig built from a real
        // self-signed cert via amaters_net's SelfSignedGenerator.  Avoiding
        // that here keeps the test scope small — we exercise only the
        // error path of swap_rustls_config which doesn't need a working
        // initial config to verify the failure surface.
        let placeholder = make_placeholder_server_config();
        let store: Arc<ArcSwap<rustls::ServerConfig>> =
            Arc::new(ArcSwap::from_pointee(placeholder));

        let result = swap_rustls_config(&store, &creds);
        assert!(
            matches!(result, Err(HotReloadError::Rustls(_))),
            "Expected Rustls error, got {:?}",
            result
        );

        fs::remove_file(&cert).ok();
        fs::remove_file(&key).ok();
    }

    /// `swap_rustls_config` with a real self-signed PEM pair succeeds.
    #[test]
    fn test_swap_rustls_config_accepts_valid_pem() {
        let _ = rustls::crypto::ring::default_provider().install_default();
        let (cert_pem, key_pem) = generate_pem_pair("swap.test");

        let creds = TlsCreds { cert_pem, key_pem };
        let placeholder = make_placeholder_server_config();
        let store: Arc<ArcSwap<rustls::ServerConfig>> =
            Arc::new(ArcSwap::from_pointee(placeholder));

        swap_rustls_config(&store, &creds).expect("swap should succeed");
        // The store now holds a non-placeholder config; we can't directly
        // compare ServerConfig but `store.load()` returning a fresh `Arc`
        // proves the swap happened.
        let _ = store.load();
    }

    // -----------------------------------------------------------------------
    // Helpers for the rustls swap tests
    // -----------------------------------------------------------------------

    /// Build a minimal placeholder `rustls::ServerConfig` suitable for use as
    /// the initial value in an `ArcSwap` for tests that only verify swap
    /// semantics (not actual TLS handshakes).
    fn make_placeholder_server_config() -> rustls::ServerConfig {
        let _ = rustls::crypto::ring::default_provider().install_default();
        let (cert_pem, key_pem) = generate_pem_pair("placeholder.test");
        let creds_ref = TlsCredsRef::new(&cert_pem, &key_pem);
        build_rustls_config(&creds_ref).expect("placeholder rustls config")
    }

    /// Generate a self-signed cert PEM pair using amaters_net's SelfSignedGenerator.
    fn generate_pem_pair(cn: &str) -> (Vec<u8>, Vec<u8>) {
        use amaters_net::tls::SelfSignedGenerator;
        use rustls::pki_types::PrivateKeyDer;
        let generator = SelfSignedGenerator::new(cn)
            .with_san(cn)
            .with_san("localhost");
        let (cert_der, key_der) = generator.generate().expect("generate cert");
        let cert_pem = pem_encode("CERTIFICATE", cert_der.as_ref());
        let key_pem = match key_der {
            PrivateKeyDer::Pkcs8(k) => pem_encode("PRIVATE KEY", k.secret_pkcs8_der()),
            PrivateKeyDer::Pkcs1(k) => pem_encode("RSA PRIVATE KEY", k.secret_pkcs1_der()),
            PrivateKeyDer::Sec1(k) => pem_encode("EC PRIVATE KEY", k.secret_sec1_der()),
            _ => panic!("unsupported key kind"),
        };
        (cert_pem, key_pem)
    }

    /// Minimal PEM encoder for tests.
    fn pem_encode(label: &str, der: &[u8]) -> Vec<u8> {
        let mut out = format!("-----BEGIN {label}-----\n").into_bytes();
        let b64 = base64_encode_test(der);
        for chunk in b64.as_bytes().chunks(64) {
            out.extend_from_slice(chunk);
            out.push(b'\n');
        }
        out.extend_from_slice(format!("-----END {label}-----\n").as_bytes());
        out
    }

    /// Tiny base64 encoder for tests (RFC 4648 standard alphabet, padding).
    fn base64_encode_test(data: &[u8]) -> String {
        const ALPHABET: &[u8; 64] =
            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
        let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
        let mut i = 0;
        while i + 3 <= data.len() {
            let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | (data[i + 2] as u32);
            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
            out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
            out.push(ALPHABET[(n & 0x3f) as usize] as char);
            i += 3;
        }
        let rem = data.len() - i;
        if rem == 1 {
            let n = (data[i] as u32) << 16;
            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
            out.push('=');
            out.push('=');
        } else if rem == 2 {
            let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
            out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
            out.push('=');
        }
        out
    }

    // -----------------------------------------------------------------------
    // SIGHUP integration test (manual / no real signal)
    // -----------------------------------------------------------------------

    /// Verify that spawn_config_reloader returns a live JoinHandle and that the
    /// underlying ReloadableConfig mechanism works via manual_reload, without
    /// actually sending SIGHUP (which is flaky in test environments).
    #[tokio::test]
    async fn test_spawn_config_reloader_returns_handle() {
        let dir = env::temp_dir();
        let path = dir.join("amaters_sighup_test_config.toml");

        let initial = make_config("127.0.0.1:7878");
        initial.save_to_file(&path).expect("save config");

        let config = Arc::new(RwLock::new(initial.clone()));
        let handle = spawn_config_reloader(path.clone(), config.clone()).await;

        // The task must be running (not finished).
        assert!(!handle.is_finished(), "Reloader task should be running");

        // Abort the background task so the test exits cleanly.
        handle.abort();

        fs::remove_file(&path).ok();
    }

    // -----------------------------------------------------------------------
    // SIGHUP integration test — #[ignore], requires real SIGHUP signal
    // -----------------------------------------------------------------------

    /// Integration test: send a real SIGHUP to the current process and verify
    /// the config is reloaded.
    ///
    /// This test is marked `#[ignore]` because it sends a real UNIX signal and
    /// is intended for manual execution only:
    ///
    /// ```sh
    /// cargo test -p amaters-server test_sighup_reloads_config -- --ignored
    /// ```
    #[cfg(unix)]
    #[tokio::test]
    #[ignore = "Integration test — sends a real SIGHUP; run manually with --ignored"]
    async fn test_sighup_reloads_config() {
        use std::time::Duration;

        let dir = env::temp_dir();
        let path = dir.join("amaters_sighup_integration_test.toml");

        let initial = make_config("127.0.0.1:7878");
        initial.save_to_file(&path).expect("save config");

        let config = Arc::new(RwLock::new(initial.clone()));
        let handle = spawn_config_reloader(path.clone(), config.clone()).await;

        // Allow the task to register the signal handler.
        tokio::time::sleep(Duration::from_millis(50)).await;

        // Modify the config file — change log level.
        let mut updated = initial.clone();
        updated.logging.level = "debug".to_string();
        updated.save_to_file(&path).expect("save updated config");

        // Send SIGHUP to self via `kill` utility (avoids needing the `libc` crate).
        let pid = std::process::id();
        let _ = std::process::Command::new("kill")
            .args(["-HUP", &pid.to_string()])
            .status()
            .expect("failed to invoke kill command");

        // Allow the handler to process the signal.
        tokio::time::sleep(Duration::from_millis(200)).await;

        assert_eq!(
            config.read().logging.level,
            "debug",
            "Expected log level to be 'debug' after SIGHUP reload"
        );

        handle.abort();
        fs::remove_file(&path).ok();
    }
}