Skip to main content

basil_core/core/
reload.rs

1//! Signal-driven hot reload of the catalog/policy **generation** (`basil-y3e`).
2//!
3//! [`reload_generation`] is the single, fail-closed reload engine shared by the
4//! SIGHUP handler and (later) the permission-scoped gRPC admin-reload follow-on
5//! (`basil-atq`). It re-reads the catalog/policy from the **same on-disk paths**
6//! the broker was started with (never from the wire), runs the **full**
7//! startup/`check` validation on the candidate, enforces that only reloadable
8//! dimensions changed, and (only on success) atomically swaps in a new
9//! [`Generation`] with a bumped id. On any failure it does **not** swap: the
10//! previous generation keeps serving and the rejection is returned to the caller
11//! (the SIGHUP handler audits it). It never panics or exits.
12//!
13//! # Reloadable vs restart-only
14//!
15//! The reloadable surface is the **content** the [`Pdp`](crate::catalog::Pdp) and
16//! the audit trail consume: the entire policy (rules / roles / name + membership
17//! tables) and the per-key *authorization* attributes: `writable`, `class`,
18//! `labels`, `description`, `missing`. The **routing shape** is restart-only:
19//! the [`BackendManager`](crate::manager::BackendManager) and the live backend
20//! instances were built from the sealed bundle at startup, so adding/removing a
21//! backend, or changing any key's `backend`/`path`/`engine`/`key_type`/
22//! `public_path`, needs a re-unlock and is rejected here (the Nix module routes
23//! such edits to `ExecStart`, i.e. a restart). [`routing_shape`] captures exactly
24//! the dimensions baked into the manager; a candidate whose shape differs from the
25//! running generation is rejected with [`ReloadError::RoutingShapeChanged`].
26//!
27//! # Non-mutating
28//!
29//! Reload is **non-mutating**: it validates (and the loader's guardrails run) but
30//! it performs **no** backend I/O and **no** CSPRNG side effects: it never
31//! reconciles or generates missing material on the signal path. A candidate that
32//! adds a `missing:error` key whose material is absent is *accepted* (its routing
33//! shape is unchanged by construction, since a new key would change the shape and
34//! be rejected anyway); a `missing:error` key that already exists in both
35//! generations simply keeps failing closed at use if its material is absent. The
36//! routing-shape guard means a reload can only ever change a *pre-existing* key's
37//! authorization attributes, never introduce a new key/backend that would demand
38//! fresh material, so there is no missing-material decision to make on the signal
39//! path beyond what startup reconcile already settled.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::sync::Arc;
43
44use crate::catalog::loader::LoadError;
45use crate::catalog::schema::{BackendKind, Capability, Class, Engine, KeyAlgorithm};
46use crate::catalog::{Catalog, Config, ResolvedPolicy, load};
47use crate::state::{BrokerState, Generation};
48
49/// The on-disk inputs a [`reload_generation`] re-reads: the configured catalog
50/// and policy JSON paths the broker was started with.
51///
52/// Stored on [`BrokerState`] at construction so the reload engine reads from the
53/// **same** paths startup used, never from anywhere else, never from the wire.
54#[derive(Debug, Clone)]
55pub struct ReloadInputs {
56    /// Path to the exported catalog JSON (the key inventory + routing table).
57    pub catalog_path: std::path::PathBuf,
58    /// Path to the exported policy JSON (the authorization allow-list).
59    pub policy_path: std::path::PathBuf,
60}
61
62/// The result of a **successful** [`reload_generation`].
63///
64/// Carries the old → new generation ids plus summary counts so the SIGHUP handler
65/// (and the future gRPC admin-reload, `basil-atq`) can log/return what changed.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct ReloadOutcome {
68    /// The generation id that was serving before the swap.
69    pub previous_generation: u64,
70    /// The generation id now serving after the atomic swap.
71    pub new_generation: u64,
72    /// Number of catalog keys in the new generation.
73    pub key_count: usize,
74    /// Number of resolved policy allow-grants in the new generation.
75    pub grant_count: usize,
76}
77
78/// Why a [`reload_generation`] was **rejected**. On any of these the previous
79/// generation keeps serving (fail closed); none of them swap.
80#[derive(Debug, thiserror::Error)]
81pub enum ReloadError {
82    /// The catalog file could not be re-read from its configured path.
83    #[error("reading catalog from {path}: {source}")]
84    ReadCatalog {
85        /// The catalog path that failed to read.
86        path: String,
87        /// The underlying IO error.
88        source: std::io::Error,
89    },
90
91    /// The policy file could not be re-read from its configured path.
92    #[error("reading policy from {path}: {source}")]
93    ReadPolicy {
94        /// The policy path that failed to read.
95        path: String,
96        /// The underlying IO error.
97        source: std::io::Error,
98    },
99
100    /// The candidate catalog/policy failed the full startup/`check` validation
101    /// (`load`, including the JWT-SVID issuer-alg and `publicPath` guardrails).
102    #[error("validating reloaded catalog/policy: {0}")]
103    Validate(#[from] LoadError),
104
105    /// The candidate changed a **restart-only** routing dimension (a backend was
106    /// added/removed/repathed, or a key's `backend`/`path`/`engine`/`key_type`/
107    /// `public_path` changed). Such an edit needs a re-unlock and is rejected on
108    /// the reload path; apply it via a restart instead.
109    #[error("reload touches a restart-only routing dimension: {0}")]
110    RoutingShapeChanged(String),
111
112    /// The broker was constructed without [`ReloadInputs`] (no configured
113    /// catalog/policy paths), so it has nothing to re-read. A reload is a no-op
114    /// fail-closed rather than reading from an unknown source.
115    #[error("reload unavailable: broker has no configured catalog/policy paths")]
116    NoInputs,
117}
118
119impl ReloadError {
120    /// A short, stable, non-secret reason token for the audit trail.
121    #[must_use]
122    pub const fn audit_reason(&self) -> &'static str {
123        match self {
124            Self::ReadCatalog { .. } => "catalog_read_failed",
125            Self::ReadPolicy { .. } => "policy_read_failed",
126            Self::Validate(_) => "validation_failed",
127            Self::RoutingShapeChanged(_) => "routing_shape_changed",
128            Self::NoInputs => "no_reload_inputs",
129        }
130    }
131}
132
133/// The restart-only **routing shape** of one backend: everything the
134/// [`BackendManager`](crate::manager::BackendManager) and capability check bake in
135/// at startup. Two generations may only differ in reloadable content if their
136/// routing shapes are equal.
137#[derive(Debug, PartialEq, Eq)]
138struct BackendShape {
139    kind: BackendKind,
140    addr: String,
141    engines: Vec<Engine>,
142    capabilities: Vec<Capability>,
143    requires: Vec<Capability>,
144}
145
146/// The restart-only routing shape of one key: the dimensions that select a
147/// backend instance, a backend-native locator, and the materialize footprint.
148/// `writable` / `class`-surface authorization is *not* here (those are
149/// reloadable), but `class` itself selects the op surface + engine inference and
150/// the materialize arm, so it is part of the shape.
151#[derive(Debug, PartialEq, Eq)]
152struct KeyShape {
153    class: Class,
154    key_type: Option<KeyAlgorithm>,
155    backend: String,
156    engine: Option<Engine>,
157    path: String,
158    public_path: Option<String>,
159}
160
161/// Project a catalog onto its restart-only routing shape: the backend set and,
162/// per key, the routing/materialize dimensions. Equal shapes ⇒ the live manager
163/// and backends still route the new generation correctly; a differing shape needs
164/// a restart.
165fn routing_shape(
166    catalog: &Catalog,
167) -> (BTreeMap<String, BackendShape>, BTreeMap<String, KeyShape>) {
168    let backends = catalog
169        .backends
170        .iter()
171        .map(|(name, b)| {
172            (
173                name.clone(),
174                BackendShape {
175                    kind: b.kind,
176                    addr: b.addr.clone(),
177                    engines: b.engines.clone(),
178                    capabilities: b.capabilities.clone(),
179                    requires: b.requires.clone(),
180                },
181            )
182        })
183        .collect();
184    let keys = catalog
185        .keys
186        .iter()
187        .map(|(name, k)| {
188            (
189                name.clone(),
190                KeyShape {
191                    class: k.class,
192                    key_type: k.key_type,
193                    backend: k.backend.clone(),
194                    engine: k.engine,
195                    path: k.path.clone(),
196                    public_path: k.public_path.clone(),
197                },
198            )
199        })
200        .collect();
201    (backends, keys)
202}
203
204/// Reject the candidate if it touches any restart-only routing dimension.
205///
206/// Compares the candidate's routing shape against the **currently serving**
207/// generation's catalog. A backend added/removed/repathed, or any key's
208/// `backend`/`path`/`engine`/`key_type`/`public_path` changed (or a key added/removed,
209/// which changes the key set, hence the shape), is restart-only.
210fn ensure_reloadable(current: &Catalog, candidate: &Catalog) -> Result<(), ReloadError> {
211    let (cur_backends, cur_keys) = routing_shape(current);
212    let (new_backends, new_keys) = routing_shape(candidate);
213    if cur_backends != new_backends {
214        return Err(ReloadError::RoutingShapeChanged(
215            "the backend set or a backend's kind/addr/engines/capabilities/requires changed"
216                .to_string(),
217        ));
218    }
219    if cur_keys != new_keys {
220        return Err(ReloadError::RoutingShapeChanged(
221            "a key was added/removed or a key's backend/path/engine/key_type/public_path changed"
222                .to_string(),
223        ));
224    }
225    Ok(())
226}
227
228fn spiffe_bundle_publishers(catalog: &Catalog) -> BTreeMap<String, (String, String)> {
229    catalog
230        .keys
231        .iter()
232        .filter_map(|(name, entry)| {
233            let svid_kind = entry.labels.get("svid_kind")?;
234            if !matches!(svid_kind, "jwt" | "x509") {
235                return None;
236            }
237            let trust_domain = entry.labels.get("trust_domain")?;
238            Some((
239                name.clone(),
240                (svid_kind.to_string(), trust_domain.to_string()),
241            ))
242        })
243        .collect()
244}
245
246fn bundle_changed_trust_domains(current: &Catalog, candidate: &Catalog) -> Vec<String> {
247    let current_publishers = spiffe_bundle_publishers(current);
248    let candidate_publishers = spiffe_bundle_publishers(candidate);
249    if current_publishers == candidate_publishers {
250        return Vec::new();
251    }
252
253    current_publishers
254        .values()
255        .chain(candidate_publishers.values())
256        .map(|(_, trust_domain)| trust_domain.clone())
257        .collect::<BTreeSet<_>>()
258        .into_iter()
259        .collect()
260}
261
262/// The fully-validated candidate generation produced by [`validate_candidate`]:
263/// the loaded surface (ready to install) plus the [`ReloadOutcome`] the swap would
264/// report. The dry-run path discards the surface and keeps only the outcome; the
265/// real reload installs the surface.
266struct ValidatedCandidate {
267    catalog: Catalog,
268    policy: ResolvedPolicy,
269    config: Config,
270    outcome: ReloadOutcome,
271    bundle_changed_trust_domains: Vec<String>,
272}
273
274/// Re-read the configured catalog/policy, run the **full** startup/`check`
275/// validation, and enforce that only reloadable dimensions changed, all **without
276/// swapping**. This is the single validation path shared by the real reload and
277/// the `--check` dry-run, so a dry-run can never diverge from what a real reload
278/// would accept (the same anti-divergence discipline the PDP's `decide`/`explain`
279/// share).
280///
281/// It is non-mutating (no backend I/O, no CSPRNG, no generation swap) and never
282/// panics. The returned [`ReloadOutcome`] reports the *would-be* generation ids
283/// and counts; it is identical to what [`reload_generation`] reports after a
284/// successful swap.
285///
286/// # Errors
287///
288/// Returns a [`ReloadError`] when the broker has no configured paths
289/// ([`ReloadError::NoInputs`]), a file cannot be re-read, the candidate fails
290/// validation ([`ReloadError::Validate`]), or it changes a restart-only routing
291/// dimension ([`ReloadError::RoutingShapeChanged`]).
292fn validate_candidate(state: &BrokerState) -> Result<ValidatedCandidate, ReloadError> {
293    let inputs = state.reload_inputs().ok_or(ReloadError::NoInputs)?;
294
295    let catalog_json = std::fs::read_to_string(&inputs.catalog_path).map_err(|source| {
296        ReloadError::ReadCatalog {
297            path: inputs.catalog_path.display().to_string(),
298            source,
299        }
300    })?;
301    let policy_json =
302        std::fs::read_to_string(&inputs.policy_path).map_err(|source| ReloadError::ReadPolicy {
303            path: inputs.policy_path.display().to_string(),
304            source,
305        })?;
306
307    // Full startup/`check` validation: load() runs every §5 hard-error check
308    // including validate_jwt_svid_issuer_alg and the publicPath guardrail.
309    let (catalog, policy, config, warnings) = load(&catalog_json, &policy_json)?;
310    for w in &warnings {
311        tracing::warn!(warning = %w, "reload: catalog/policy load warning");
312    }
313
314    // Pin the currently-serving generation to (a) compare routing shape against,
315    // and (b) read the previous id to bump from: one coherent snapshot.
316    let current = state.load_generation();
317    ensure_reloadable(current.catalog(), &catalog)?;
318
319    let previous_generation = current.id();
320    let new_generation = previous_generation.saturating_add(1);
321    let bundle_changed_trust_domains = bundle_changed_trust_domains(current.catalog(), &catalog);
322    let outcome = ReloadOutcome {
323        previous_generation,
324        new_generation,
325        key_count: catalog.keys.len(),
326        grant_count: policy.grant_count(),
327    };
328
329    Ok(ValidatedCandidate {
330        catalog,
331        policy,
332        config,
333        outcome,
334        bundle_changed_trust_domains,
335    })
336}
337
338/// Validate the candidate catalog/policy **without** swapping (the `--check`
339/// dry-run, basil-atq).
340///
341/// Runs the *identical* validation [`reload_generation`] runs (re-read from disk,
342/// full `load()` validation, and the restart-only routing-shape guard) but
343/// performs **no** generation swap: the currently-serving generation is untouched.
344/// The returned [`ReloadOutcome`] reports what a real reload *would* apply (the
345/// would-be new generation id + counts).
346///
347/// # Errors
348///
349/// The same [`ReloadError`] set as [`reload_generation`]; on any error the running
350/// generation keeps serving (it was never going to change here regardless).
351pub fn check_reload(state: &BrokerState) -> Result<ReloadOutcome, ReloadError> {
352    validate_candidate(state).map(|c| c.outcome)
353}
354
355/// Re-read the configured catalog/policy, validate the candidate, enforce that
356/// only reloadable dimensions changed, and on success atomically swap in a new
357/// [`Generation`] with a bumped id.
358///
359/// This is the **one** fail-closed reload code path, shared by the SIGHUP handler
360/// and the gRPC admin-reload follow-on (`basil-atq`). It is non-mutating up to the
361/// final swap (no backend I/O, no CSPRNG) and never panics. The validation it runs
362/// is exactly [`check_reload`]'s (they share [`validate_candidate`]), so a
363/// dry-run that passes guarantees the real reload's validation passes too.
364///
365/// # Errors
366///
367/// Returns a [`ReloadError`] (without swapping, so the previous generation keeps
368/// serving) when the broker has no configured paths ([`ReloadError::NoInputs`]),
369/// a file cannot be re-read, the candidate fails validation
370/// ([`ReloadError::Validate`]), or the candidate changes a restart-only routing
371/// dimension ([`ReloadError::RoutingShapeChanged`]).
372pub fn reload_generation(state: &BrokerState) -> Result<ReloadOutcome, ReloadError> {
373    let candidate = validate_candidate(state)?;
374    let ValidatedCandidate {
375        catalog,
376        policy,
377        config,
378        outcome,
379        bundle_changed_trust_domains,
380    } = candidate;
381
382    let next = Generation::new(outcome.new_generation, Arc::new(catalog), policy, config);
383    state.swap_generation(Arc::new(next));
384    for trust_domain in bundle_changed_trust_domains {
385        state.events().bundle_changed(trust_domain);
386    }
387
388    Ok(outcome)
389}
390
391#[cfg(test)]
392mod tests {
393    use std::collections::BTreeMap;
394    use std::sync::Arc;
395
396    use async_trait::async_trait;
397    use basil_proto::KeyType;
398
399    use super::{ReloadError, ReloadInputs, check_reload, reload_generation};
400    use crate::backend::{Backend, BackendError, NewKey};
401    use crate::catalog::load;
402    use crate::manager::BackendManager;
403    use crate::state::{BrokerState, INITIAL_GENERATION_ID};
404
405    /// A no-op backend: reload is non-mutating and never calls the backend, so the
406    /// required trait methods all fail closed (the manager only needs them present
407    /// to satisfy `Backend`).
408    struct NoopBackend;
409
410    #[async_trait]
411    impl Backend for NoopBackend {
412        fn kind(&self) -> &'static str {
413            "noop"
414        }
415        async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
416            Err(BackendError::Unsupported("new_key"))
417        }
418        async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
419            Err(BackendError::Unsupported("public_key"))
420        }
421        async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
422            Err(BackendError::Unsupported("sign"))
423        }
424        async fn verify(
425            &self,
426            _key_id: &str,
427            _message: &[u8],
428            _signature: &[u8],
429        ) -> Result<bool, BackendError> {
430            Err(BackendError::Unsupported("verify"))
431        }
432    }
433
434    /// A one-key, one-backend catalog. `writable` is reloadable; the routing shape
435    /// (`backend`/`path`/`engine`/`key_type`) is fixed across the variants below.
436    fn catalog_json(writable: bool) -> String {
437        format!(
438            r#"{{
439              "schemaVersion": 1,
440              "backends": {{ "bao": {{ "kind": "vault", "addr": "http://127.0.0.1:8200" }} }},
441              "keys": {{
442                "web.signer": {{
443                  "class": "asymmetric", "keyType": "ed25519", "backend": "bao",
444                  "path": "signer", "writable": {writable}, "description": "a signer"
445                }}
446              }}
447            }}"#
448        )
449    }
450
451    /// A catalog whose key routes to a DIFFERENT path: a restart-only change.
452    fn catalog_json_repathed() -> String {
453        r#"{
454          "schemaVersion": 1,
455          "backends": { "bao": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
456          "keys": {
457            "web.signer": {
458              "class": "asymmetric", "keyType": "ed25519", "backend": "bao",
459              "path": "signer-v2", "writable": true, "description": "a signer"
460            }
461          }
462        }"#
463        .to_string()
464    }
465
466    fn policy_json(grant_sign: bool) -> String {
467        let rules = if grant_sign {
468            r#"[ { "id": "r1", "subjects": ["svc.web"], "action": ["op:sign"], "target": ["web.signer"] } ]"#
469        } else {
470            "[]"
471        };
472        format!(
473            r#"{{
474              "schemaVersion": 2,
475              "subjects": {{ "svc.web": {{ "allOf": [ {{ "kind": "unix", "uid": 1000 }} ] }} }},
476              "roles": {{}},
477              "rules": {rules},
478              "config": {{}}
479            }}"#
480        )
481    }
482
483    /// Build a [`BrokerState`] from catalog/policy JSON written to temp files, with
484    /// the reload inputs pointed at those files so the engine re-reads them.
485    fn state_with_files(catalog: &str, policy: &str) -> (Arc<BrokerState>, ReloadInputs) {
486        let dir = std::env::temp_dir().join(format!(
487            "basil-reload-test-{}-{}",
488            std::process::id(),
489            uuid::Uuid::new_v4()
490        ));
491        std::fs::create_dir_all(&dir).expect("create temp dir");
492        let catalog_path = dir.join("catalog.json");
493        let policy_path = dir.join("policy.json");
494        std::fs::write(&catalog_path, catalog).expect("write catalog");
495        std::fs::write(&policy_path, policy).expect("write policy");
496
497        let (cat, pol, cfg, warnings) = load(catalog, policy).expect("fixture loads");
498        assert!(warnings.is_empty());
499        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
500        backends.insert("bao".into(), Box::new(NoopBackend));
501        let manager = BackendManager::new(cat.clone(), backends).expect("manager builds");
502        let inputs = ReloadInputs {
503            catalog_path,
504            policy_path,
505        };
506        let state = Arc::new(
507            BrokerState::new(cat, pol, cfg, manager, "noop").with_reload_inputs(inputs.clone()),
508        );
509        (state, inputs)
510    }
511
512    fn write_files(inputs: &ReloadInputs, catalog: &str, policy: &str) {
513        std::fs::write(&inputs.catalog_path, catalog).expect("rewrite catalog");
514        std::fs::write(&inputs.policy_path, policy).expect("rewrite policy");
515    }
516
517    /// A valid reload (a reloadable-dimension edit) swaps to a new generation id,
518    /// and a guard pinned BEFORE the swap still sees the old generation while a
519    /// fresh load sees the new one: the reload-between-two-reads coherence the
520    /// pinning plumbing (y3e.1) could not exercise without a trigger.
521    #[test]
522    fn valid_reload_swaps_generation_and_stays_coherent() {
523        let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
524        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
525
526        // An in-flight op pins the current generation BEFORE the reload.
527        let pinned = state.load_generation();
528        assert_eq!(pinned.id(), INITIAL_GENERATION_ID);
529
530        // Edit a reloadable dimension (flip writable + add a sign grant).
531        write_files(&inputs, &catalog_json(true), &policy_json(true));
532        let outcome = reload_generation(&state).expect("valid reload applies");
533
534        assert_eq!(outcome.previous_generation, INITIAL_GENERATION_ID);
535        assert_eq!(outcome.new_generation, INITIAL_GENERATION_ID + 1);
536        assert_eq!(outcome.key_count, 1);
537        assert_eq!(outcome.grant_count, 1);
538
539        // The pre-swap pin still sees the OLD generation (coherent in-flight read);
540        // a fresh load sees the NEW one.
541        assert_eq!(pinned.id(), INITIAL_GENERATION_ID);
542        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 1);
543    }
544
545    /// An invalid candidate (malformed policy) is REJECTED, the previous
546    /// generation keeps serving, and the engine never panics.
547    #[test]
548    fn invalid_policy_is_rejected_and_previous_generation_keeps_serving() {
549        let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
550
551        // Corrupt the policy: reference a role that is not declared (§5 hard error
552        // UnknownRole), the catalog is unchanged, so this isolates a *validation*
553        // rejection from the routing-shape guard.
554        write_files(
555            &inputs,
556            &catalog_json(true),
557            r#"{ "schemaVersion": 2, "subjects": { "svc.web": { "allOf": [ { "kind": "unix", "uid": 1000 } ] } }, "roles": {}, "rules": [ { "id": "bad", "subjects": ["svc.web"], "action": ["role:nonexistent"], "target": ["web.signer"] } ], "config": {} }"#,
558        );
559
560        let err = reload_generation(&state).expect_err("malformed policy rejected");
561        assert!(matches!(err, ReloadError::Validate(_)));
562        assert_eq!(err.audit_reason(), "validation_failed");
563        // Previous generation untouched.
564        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
565    }
566
567    /// A non-profile JWT-SVID issuer candidate is rejected: the loader's fail-closed
568    /// issuer-alg guardrail runs on the reload path (validation), so the broker
569    /// never swaps in a generation that would mint SPIFFE-rejected tokens.
570    #[test]
571    fn non_profile_jwt_svid_issuer_is_rejected_on_reload() {
572        // Base: an RSA JWT-SVID issuer (loads at startup).
573        let base_catalog = r#"{
574          "schemaVersion": 1,
575          "backends": { "bao": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
576          "keys": {
577            "spiffe.jwt": {
578              "class": "asymmetric", "keyType": "rsa-2048", "backend": "bao", "path": "jwt",
579              "labels": ["svid_kind=jwt", "trust_domain=example.org"],
580              "writable": false, "description": "jwt issuer"
581            }
582          }
583        }"#;
584        let (state, inputs) = state_with_files(base_catalog, &policy_json(false));
585
586        // Candidate flips the issuer to ed25519 (EdDSA): a non-profile alg.
587        let bad_catalog = base_catalog.replace("rsa-2048", "ed25519");
588        write_files(&inputs, &bad_catalog, &policy_json(false));
589
590        let err = reload_generation(&state).expect_err("non-profile jwt issuer rejected");
591        // It is caught (either by the alg guardrail in validation, or, since the
592        // key_type is part of the routing shape, by the restart-only guard);
593        // either way the reload fails closed and the prior generation serves on.
594        assert!(matches!(
595            err,
596            ReloadError::Validate(_) | ReloadError::RoutingShapeChanged(_)
597        ));
598        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
599    }
600
601    /// A restart-only edit (a key repathed to a different backend locator) is
602    /// rejected: the live manager/backends cannot re-route without a restart.
603    #[test]
604    fn restart_only_routing_change_is_rejected() {
605        let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
606        write_files(&inputs, &catalog_json_repathed(), &policy_json(true));
607
608        let err = reload_generation(&state).expect_err("repath rejected");
609        assert!(matches!(err, ReloadError::RoutingShapeChanged(_)));
610        assert_eq!(err.audit_reason(), "routing_shape_changed");
611        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
612    }
613
614    /// `check_reload` (the `--check` dry-run) validates the candidate and reports
615    /// the would-be outcome WITHOUT swapping: the serving generation id is
616    /// unchanged, and a subsequent real reload applies the very same outcome.
617    #[test]
618    fn check_reload_validates_without_swapping() {
619        let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
620        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
621
622        write_files(&inputs, &catalog_json(true), &policy_json(true));
623        let dry = check_reload(&state).expect("dry-run validates");
624        assert_eq!(dry.previous_generation, INITIAL_GENERATION_ID);
625        assert_eq!(dry.new_generation, INITIAL_GENERATION_ID + 1);
626        assert_eq!(dry.key_count, 1);
627        assert_eq!(dry.grant_count, 1);
628        // The serving generation is UNCHANGED by the dry-run.
629        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
630
631        // A real reload now applies exactly what the dry-run previewed.
632        let applied = reload_generation(&state).expect("real reload applies");
633        assert_eq!(applied, dry);
634        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 1);
635    }
636
637    /// A rejected candidate is rejected identically by the dry-run and the real
638    /// reload, and neither swaps: the dry-run never diverges from enforcement.
639    #[test]
640    fn check_reload_rejects_what_real_reload_rejects() {
641        let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
642        write_files(&inputs, &catalog_json_repathed(), &policy_json(true));
643
644        let dry = check_reload(&state).expect_err("dry-run rejects repath");
645        assert!(matches!(dry, ReloadError::RoutingShapeChanged(_)));
646        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
647
648        let real = reload_generation(&state).expect_err("real reload rejects repath");
649        assert!(matches!(real, ReloadError::RoutingShapeChanged(_)));
650        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
651    }
652
653    /// A broker with no configured paths fails the reload closed (no-op), never
654    /// reading catalog/policy from an unconfigured source.
655    #[test]
656    fn reload_without_inputs_fails_closed() {
657        let (cat, pol, cfg, _) =
658            load(&catalog_json(true), &policy_json(true)).expect("fixture loads");
659        let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
660        backends.insert("bao".into(), Box::new(NoopBackend));
661        let manager = BackendManager::new(cat.clone(), backends).expect("manager builds");
662        let state = BrokerState::new(cat, pol, cfg, manager, "noop");
663
664        let err = reload_generation(&state).expect_err("no inputs → fail closed");
665        assert!(matches!(err, ReloadError::NoInputs));
666        assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
667    }
668}