Skip to main content

zeph_durable/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Durable configuration and the AEAD enforcement gate.
5//!
6//! The pure-data configuration types ([`DurableConfig`], [`RetentionPolicy`], [`DurableBackend`])
7//! live in `zeph-config` so the aggregate [`Config`](zeph_config::Config) can hold them without
8//! pulling this crate's `zeph-db`/`sqlx` dependency tree onto the config layer. They are re-exported
9//! here for ergonomic access from the engine APIs that consume them ([`DurableContext`] and
10//! [`JournalWriter`]).
11//!
12//! On top of the data, this module owns the **security policy**: [`encryption_gate`] evaluates the
13//! INV-8 AEAD requirement for a deployment. The policy lives next to [`DurableError`] and the cipher
14//! contract (in this crate), not with the pure data.
15//!
16//! [`DurableContext`]: crate::DurableContext
17//! [`JournalWriter`]: crate::JournalWriter
18
19pub use zeph_config::{DurableBackend, DurableConfig, RetentionPolicy};
20
21use crate::error::DurableError;
22
23/// Outcome of evaluating the INV-8 AEAD requirement for a deployment.
24///
25/// Returned by [`encryption_gate`]. The error case ([`DurableError::EncryptionRequired`]) covers the
26/// forbidden combinations; this enum distinguishes the two *permitted* outcomes so the caller can act
27/// on the development-override warning.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EncryptionGate {
30    /// AEAD payload encryption is enabled — proceed normally.
31    Enabled,
32    /// AEAD is disabled on a single-user local backend (a development override). The caller MUST
33    /// emit a startup `WARN` so the weakened posture is visible in the logs.
34    DisabledLocalWarn,
35}
36
37/// Evaluate whether the configured `encrypt_payload` setting is permitted for this deployment
38/// (INV-8).
39///
40/// AEAD is default-on. Disabling it is a development-only override that is permitted **only** for a
41/// single-user local backend on a non-shared database. `shared_db` MUST be `true` whenever the
42/// journal lives on a multi-client database (Postgres, or any file shared across processes), where
43/// the DB-file trust boundary does not hold.
44///
45/// # Errors
46///
47/// Returns [`DurableError::EncryptionRequired`] when `encrypt_payload = false` is combined with a
48/// non-local backend or a shared database.
49///
50/// # Examples
51///
52/// ```
53/// use zeph_durable::{DurableBackend, DurableConfig, EncryptionGate, encryption_gate};
54///
55/// // Default config keeps AEAD on regardless of deployment.
56/// let cfg = DurableConfig::default();
57/// assert_eq!(encryption_gate(&cfg, true).unwrap(), EncryptionGate::Enabled);
58///
59/// // Disabling AEAD is tolerated only on a single-user local backend.
60/// let dev = DurableConfig { encrypt_payload: false, ..DurableConfig::default() };
61/// assert_eq!(encryption_gate(&dev, false).unwrap(), EncryptionGate::DisabledLocalWarn);
62/// assert!(encryption_gate(&dev, true).is_err(), "forbidden on a shared database");
63/// ```
64pub fn encryption_gate(
65    cfg: &DurableConfig,
66    shared_db: bool,
67) -> Result<EncryptionGate, DurableError> {
68    if cfg.encrypt_payload {
69        return Ok(EncryptionGate::Enabled);
70    }
71    if cfg.backend != DurableBackend::Local {
72        return Err(DurableError::EncryptionRequired { context: "restate" });
73    }
74    if shared_db {
75        return Err(DurableError::EncryptionRequired {
76            context: "shared-database",
77        });
78    }
79    Ok(EncryptionGate::DisabledLocalWarn)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use std::assert_matches;
86
87    #[test]
88    fn encryption_gate_passes_when_aead_enabled() {
89        let cfg = DurableConfig::default();
90        assert!(cfg.encrypt_payload);
91        assert_eq!(
92            encryption_gate(&cfg, false).unwrap(),
93            EncryptionGate::Enabled
94        );
95        assert_eq!(
96            encryption_gate(&cfg, true).unwrap(),
97            EncryptionGate::Enabled
98        );
99        let restate = DurableConfig {
100            backend: DurableBackend::Restate,
101            ..DurableConfig::default()
102        };
103        assert_eq!(
104            encryption_gate(&restate, true).unwrap(),
105            EncryptionGate::Enabled
106        );
107    }
108
109    #[test]
110    fn encryption_gate_warns_for_local_single_user_override() {
111        let cfg = DurableConfig {
112            encrypt_payload: false,
113            backend: DurableBackend::Local,
114            ..DurableConfig::default()
115        };
116        assert_eq!(
117            encryption_gate(&cfg, false).unwrap(),
118            EncryptionGate::DisabledLocalWarn
119        );
120    }
121
122    #[test]
123    fn encryption_gate_rejects_disabled_aead_on_shared_or_restate() {
124        let local_shared = DurableConfig {
125            encrypt_payload: false,
126            backend: DurableBackend::Local,
127            ..DurableConfig::default()
128        };
129        assert_matches!(
130            encryption_gate(&local_shared, true),
131            Err(DurableError::EncryptionRequired {
132                context: "shared-database"
133            })
134        );
135
136        let restate = DurableConfig {
137            encrypt_payload: false,
138            backend: DurableBackend::Restate,
139            ..DurableConfig::default()
140        };
141        assert_matches!(
142            encryption_gate(&restate, false),
143            Err(DurableError::EncryptionRequired { context: "restate" })
144        );
145    }
146}