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}