use std::path::Path;
use std::sync::Arc;
use anyhow::Context as _;
use crate::bootstrap::load_config_or_default;
use zeph_core::config::Config;
use zeph_core::durable::XChaCha20Poly1305Cipher;
use zeph_core::vault::AgeVaultProvider;
use zeph_durable::{ExecutionId, Journal, LocalBackend};
use crate::cli::DurableCommand;
const REVEAL_WARNING: &str =
"WARNING: --reveal decrypts and prints payload bytes in cleartext. Do not share this output.";
pub(crate) fn resolve_durable_db_url(config: &Config) -> String {
let main = config.memory.sqlite_path.as_str();
let Some(dir) = Path::new(main)
.parent()
.filter(|d| !d.as_os_str().is_empty())
else {
return "durable.db".to_owned();
};
let legacy = dir.join("durable.db");
if legacy.exists() {
return legacy.to_string_lossy().into_owned();
}
let file_name = Path::new(main).file_name().map_or_else(
|| "zeph.db".to_owned(),
|s| s.to_string_lossy().into_owned(),
);
dir.join(format!("{file_name}.durable.db"))
.to_string_lossy()
.into_owned()
}
fn fmt_ts(ms: i64) -> String {
chrono::DateTime::from_timestamp_millis(ms).map_or_else(
|| ms.to_string(),
|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
)
}
fn is_shared_db(config: &zeph_core::config::DurableConfig, url: &str) -> bool {
config.shared_db || url.starts_with("postgres://") || url.starts_with("postgresql://")
}
pub(crate) fn enforce_encryption_gate(
config: &zeph_core::config::DurableConfig,
url: &str,
) -> anyhow::Result<()> {
let shared_db = is_shared_db(config, url);
match zeph_durable::encryption_gate(config, shared_db) {
Ok(zeph_durable::EncryptionGate::Enabled) => Ok(()),
Ok(zeph_durable::EncryptionGate::DisabledLocalWarn) => {
tracing::warn!(
"durable: AEAD payload encryption is disabled (encrypt_payload = false); \
journal payloads are stored in plaintext. This is a development-only override, \
permitted only for a single-user local, non-shared database (INV-8)."
);
Ok(())
}
Err(e) => Err(anyhow::Error::new(e).context("durable execution security policy")),
}
}
fn load_durable_cipher() -> anyhow::Result<XChaCha20Poly1305Cipher> {
let dir = zeph_core::vault::default_vault_dir();
let provider = AgeVaultProvider::load(&dir.join("vault-key.txt"), &dir.join("secrets.age"))
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
let key = provider.get("ZEPH_DURABLE_KEY").ok_or_else(|| {
anyhow::anyhow!("ZEPH_DURABLE_KEY not found in vault; cannot --reveal payloads")
})?;
XChaCha20Poly1305Cipher::from_vault_b64(key)
.map_err(|e| anyhow::anyhow!("invalid ZEPH_DURABLE_KEY: {e}"))
}
fn load_control_hmac_key(
config: &zeph_core::config::DurableConfig,
url: &str,
) -> anyhow::Result<Option<[u8; 32]>> {
if !is_shared_db(config, url) {
return Ok(None);
}
let dir = zeph_core::vault::default_vault_dir();
let provider = AgeVaultProvider::load(&dir.join("vault-key.txt"), &dir.join("secrets.age"))
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
let key = provider.get("ZEPH_DURABLE_KEY").ok_or_else(|| {
anyhow::anyhow!(
"ZEPH_DURABLE_KEY not found in vault; required to compute the control-entry row HMAC \
on a shared database (INV-8)"
)
})?;
let hmac_key = zeph_core::durable::derive_control_hmac_key_b64(key).map_err(|e| {
anyhow::anyhow!("invalid ZEPH_DURABLE_KEY for control-entry HMAC derivation: {e}")
})?;
Ok(Some(hmac_key))
}
pub(crate) fn load_write_hmac_key(config: &Config) -> anyhow::Result<Option<[u8; 32]>> {
let url = resolve_durable_db_url(config);
load_control_hmac_key(&config.durable, &url)
}
pub(crate) fn load_write_cipher(
config: &Config,
) -> anyhow::Result<Option<Arc<dyn zeph_durable::PayloadCipher>>> {
let url = resolve_durable_db_url(config);
enforce_encryption_gate(&config.durable, &url)?;
if !config.durable.encrypt_payload {
return Ok(None);
}
let cipher = load_durable_cipher()?;
Ok(Some(Arc::new(cipher)))
}
async fn open_backend(config: &Config, reveal: bool) -> anyhow::Result<Option<LocalBackend>> {
let url = resolve_durable_db_url(config);
enforce_encryption_gate(&config.durable, &url)?;
let hmac_key = load_control_hmac_key(&config.durable, &url)?;
if url != ":memory:" && !Path::new(&url).exists() {
println!(
"No durable journal at {url}.\n\
Durable execution may be disabled; enable it with `[durable] enabled = true`."
);
return Ok(None);
}
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.map_err(|e| anyhow::anyhow!("failed to open durable journal: {e}"))?;
backend
.init()
.await
.map_err(|e| anyhow::anyhow!("failed to initialize durable schema: {e}"))?;
let backend = if let Some(key) = hmac_key {
backend.with_hmac_key(key)
} else {
backend
};
if reveal && config.durable.encrypt_payload {
let cipher = load_durable_cipher()?;
Ok(Some(backend.with_cipher(Arc::new(cipher))))
} else {
Ok(Some(backend))
}
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn handle_durable_command(
cmd: DurableCommand,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let config_file = crate::bootstrap::resolve_config_path(config_path);
let config = load_config_or_default(&config_file);
match cmd {
DurableCommand::List {
status,
kind,
limit,
json,
} => {
let Some(backend) = open_backend(&config, false).await? else {
return Ok(());
};
let rows = backend
.list_executions(status.as_deref(), kind.as_deref(), limit)
.await
.map_err(|e| anyhow::anyhow!("failed to list executions: {e}"))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&rows)
.context("failed to serialize execution list")?
);
} else {
if rows.is_empty() {
println!("No durable executions match.");
return Ok(());
}
println!(
"{:<36} {:<18} {:<10} {:>6} CREATED",
"EXECUTION ID", "KIND", "STATUS", "STEPS"
);
println!("{}", "-".repeat(96));
for row in &rows {
println!(
"{:<36} {:<18} {:<10} {:>6} {}",
row.execution_id.as_uuid(),
row.kind,
row.status.as_str(),
row.step_count,
fmt_ts(row.created_at_ms),
);
}
}
}
DurableCommand::Show { id, reveal, json } => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) = open_backend(&config, reveal).await? else {
return Ok(());
};
show_entries(&backend, exec, reveal, None, json).await?;
}
DurableCommand::Inspect {
id,
step,
reveal,
json,
} => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) = open_backend(&config, reveal).await? else {
return Ok(());
};
show_entries(&backend, exec, reveal, Some(step), json).await?;
}
DurableCommand::Prune { dry_run } => {
let Some(backend) = open_backend(&config, false).await? else {
return Ok(());
};
let policy = &config.durable.retention;
if dry_run {
let orphans = backend
.count_orphans(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to count orphaned executions: {e}"))?;
println!("Dry run: {orphans} orphaned execution(s) would be aborted.");
let n = backend
.count_prunable(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to count prunable executions: {e}"))?;
println!("Dry run: {n} terminal execution(s) past TTL would be pruned.");
} else {
let aborted = backend
.sweep_orphans(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to sweep orphaned executions: {e}"))?;
println!("Aborted {aborted} orphaned execution(s).");
let n = backend
.prune(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to prune journal: {e}"))?;
println!("Pruned {n} execution(s).");
}
}
DurableCommand::Resume { id } => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) = open_backend(&config, false).await? else {
return Ok(());
};
let entries = backend
.read_execution_redacted(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if entries.is_empty() {
println!("No journal entries found for execution {id}.");
return Ok(());
}
println!(
"Execution {id} has {} journaled step(s).\n\
Automatic resume is performed by the agent process for supported execution kinds; \
standalone CLI replay is not available in this build (durable adapters A1-A4).",
entries.len()
);
}
}
Ok(())
}
async fn show_entries(
backend: &LocalBackend,
exec: ExecutionId,
reveal: bool,
step: Option<u32>,
json: bool,
) -> anyhow::Result<()> {
if reveal {
println!("{REVEAL_WARNING}\n");
let mut entries = backend
.read_execution(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if let Some(s) = step {
entries.retain(|e| e.step_id.value() == s);
}
if entries.is_empty() {
println!("No matching journal entry.");
} else {
print_revealed(&entries);
}
} else {
let mut entries = backend
.read_execution_redacted(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if let Some(s) = step {
entries.retain(|e| e.step_id.value() == s);
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&entries)
.context("failed to serialize journal entries")?
);
} else if entries.is_empty() {
println!("No matching journal entry.");
} else {
print_redacted(&entries);
}
}
Ok(())
}
fn print_redacted(entries: &[zeph_durable::RedactedEntry]) {
if entries.is_empty() {
println!("No journal entries.");
return;
}
println!(
"{:>6} {:>6} {:<14} {:<20} {:>10} {:<18} CREATED",
"SEQ", "STEP", "ENTRY KIND", "EFFECT CLASS", "BYTES", "IDEM KEY"
);
println!("{}", "-".repeat(96));
for e in entries {
println!(
"{:>6} {:>6} {:<14} {:<20} {:>10} {:<18} {}",
e.seq,
e.step_id.value(),
e.entry_kind,
e.effect_class.as_deref().unwrap_or("-"),
e.payload_len,
e.idem_key_prefix.as_deref().unwrap_or("-"),
fmt_ts(e.created_at_ms),
);
}
}
fn print_revealed(entries: &[zeph_durable::JournalEntry]) {
use zeph_durable::EntryKind;
if entries.is_empty() {
println!("No journal entries.");
return;
}
for e in entries {
let step = e.step_id.value();
match &e.entry {
EntryKind::StepResult {
payload, effect, ..
} => {
println!(
"step {step} [step_result, {effect:?}] {} bytes:",
payload.len()
);
println!(" {}", String::from_utf8_lossy(payload));
}
other => {
println!("step {step} [{}] (no payload)", other.tag());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn durable_db_is_filename_namespaced_sibling_of_sqlite_path() {
let mut config = Config::default();
config.memory.sqlite_path = "/data/zeph/zeph.db".to_owned();
assert_eq!(
resolve_durable_db_url(&config),
"/data/zeph/zeph.db.durable.db"
);
}
#[test]
fn distinct_sqlite_stems_resolve_to_distinct_durable_urls() {
let dir = tempfile::tempdir().unwrap();
let mut config_a = Config::default();
config_a.memory.sqlite_path = dir.path().join("alpha.db").to_string_lossy().into_owned();
let mut config_b = Config::default();
config_b.memory.sqlite_path = dir.path().join("beta.db").to_string_lossy().into_owned();
assert_ne!(
resolve_durable_db_url(&config_a),
resolve_durable_db_url(&config_b)
);
}
#[test]
fn same_stem_different_extension_resolves_to_distinct_durable_urls() {
let dir = tempfile::tempdir().unwrap();
let mut config_a = Config::default();
config_a.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
let mut config_b = Config::default();
config_b.memory.sqlite_path = dir
.path()
.join("zeph.sqlite")
.to_string_lossy()
.into_owned();
assert_ne!(
resolve_durable_db_url(&config_a),
resolve_durable_db_url(&config_b)
);
}
#[test]
fn preexisting_legacy_durable_db_is_preferred() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("durable.db"), []).unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
assert_eq!(
resolve_durable_db_url(&config),
dir.path().join("durable.db").to_string_lossy()
);
}
#[tokio::test]
async fn distinct_databases_do_not_collide_on_shared_directory() {
let dir = tempfile::tempdir().unwrap();
let sqlite_a = dir.path().join("alpha.db").to_string_lossy().into_owned();
let sqlite_b = dir.path().join("beta.db").to_string_lossy().into_owned();
let mut config_a = Config::default();
config_a.memory.sqlite_path = sqlite_a.clone();
let mut config_b = Config::default();
config_b.memory.sqlite_path = sqlite_b.clone();
let url_a = resolve_durable_db_url(&config_a);
let url_b = resolve_durable_db_url(&config_b);
assert_ne!(
url_a, url_b,
"the two databases must resolve to distinct journal files"
);
let backend_a = LocalBackend::open(&url_a, 1_000_000).await.unwrap();
backend_a.init().await.unwrap();
let backend_b = LocalBackend::open(&url_b, 1_000_000).await.unwrap();
backend_b.init().await.unwrap();
assert!(
Path::new(&url_a).exists() && Path::new(&url_b).exists(),
"both journal files must actually exist on disk"
);
assert_ne!(
std::fs::canonicalize(&url_a).unwrap(),
std::fs::canonicalize(&url_b).unwrap(),
"the two journal files must be genuinely distinct files, not the same file via aliasing"
);
let conv_one = 1u64.to_le_bytes();
let fold = |sqlite_path: &str| {
let mut payload = conv_one.to_vec();
payload.extend_from_slice(sqlite_path.as_bytes());
ExecutionId::derive(b"zeph.agent_turn.v1", &payload)
};
let exec_a = fold(&sqlite_a);
let exec_b = fold(&sqlite_b);
assert_ne!(exec_a, exec_b);
let resumed_a = backend_a
.open_execution(exec_a, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
let resumed_b = backend_b
.open_execution(exec_b, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
assert!(
!resumed_a && !resumed_b,
"each database's first conversation must open a fresh execution, not resume the other's"
);
let executions_a = backend_a.list_executions(None, None, 10).await.unwrap();
let executions_b = backend_b.list_executions(None, None, 10).await.unwrap();
assert_eq!(executions_a.len(), 1);
assert_eq!(executions_b.len(), 1);
assert_eq!(executions_a[0].execution_id, exec_a);
assert_eq!(executions_b[0].execution_id, exec_b);
}
#[test]
fn durable_db_bare_filename_when_path_has_no_parent() {
let mut config = Config::default();
config.memory.sqlite_path = "zeph.db".to_owned();
assert_eq!(resolve_durable_db_url(&config), "durable.db");
}
#[test]
fn fmt_ts_formats_epoch_millis_as_utc() {
assert_eq!(fmt_ts(0), "1970-01-01 00:00:00");
}
#[tokio::test]
async fn open_backend_reveal_succeeds_without_key_when_encrypt_payload_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = false;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let backend = open_backend(&config, true)
.await
.expect("--reveal must succeed without ZEPH_DURABLE_KEY when encrypt_payload=false");
assert!(backend.is_some());
}
#[tokio::test]
async fn open_backend_rejects_disabled_encryption_when_shared_db_declared() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = false;
config.durable.shared_db = true;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let result = open_backend(&config, false).await;
assert!(
result.is_err(),
"open_backend must fail closed for encrypt_payload=false on a declared shared_db (INV-8)"
);
let reveal_result = open_backend(&config, true).await;
assert!(
reveal_result.is_err(),
"open_backend --reveal must fail closed for the same forbidden combination"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn open_backend_reveal_requires_key_when_encrypt_payload_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = true;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let result = open_backend(&config, true).await;
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_err(),
"expected --reveal to fail without ZEPH_DURABLE_KEY when encrypt_payload=true"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn write_path_attaches_cipher_and_seals_payload_when_encrypt_payload_enabled() {
use zeph_durable::{
EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry, StepId,
};
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = true;
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let cipher = load_write_cipher(&config)
.expect("cipher load must succeed with a real vault key")
.expect("encrypt_payload=true must produce a cipher");
let url = resolve_durable_db_url(&config);
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap()
.with_cipher(cipher);
backend.init().await.unwrap();
let exec = ExecutionId::new();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(0);
let plaintext: &[u8] = br#"{"secret":"token-value"}"#;
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:test"),
payload: bytes::Bytes::copy_from_slice(plaintext),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
let (stored,): (Option<Vec<u8>>,) = zeph_db::query_as(zeph_db::sql!(
"SELECT payload FROM durable_journal WHERE execution_id = ?"
))
.bind(exec.as_uuid().to_string())
.fetch_one(backend.pool())
.await
.unwrap();
let stored = stored.expect("payload present");
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert_ne!(
stored.as_slice(),
plaintext,
"payload must not be stored verbatim when encrypt_payload=true"
);
assert!(
serde_json::from_slice::<serde_json::Value>(&stored).is_err(),
"sealed payload must not parse as plaintext JSON"
);
let entries = backend.read_execution(exec).await.unwrap();
match &entries[0].entry {
EntryKind::StepResult { payload, .. } => assert_eq!(payload.as_ref(), plaintext),
other => panic!("unexpected entry kind: {other:?}"),
}
}
#[tokio::test]
async fn load_write_cipher_rejects_disabled_encryption_when_shared_db_declared() {
let mut config = Config::default();
config.durable.encrypt_payload = false;
config.durable.shared_db = true;
assert!(
load_write_cipher(&config).is_err(),
"encrypt_payload=false on a declared shared_db must fail closed (INV-8)"
);
}
#[tokio::test]
async fn load_write_cipher_warns_but_succeeds_for_undeclared_local_override() {
let mut config = Config::default();
config.durable.encrypt_payload = false;
assert!(load_write_cipher(&config).unwrap().is_none());
}
#[test]
fn is_shared_db_detects_postgres_url_scheme_even_when_flag_unset() {
let config = zeph_core::config::DurableConfig::default();
assert!(!config.shared_db);
assert!(is_shared_db(&config, "postgres://user@host/db"));
assert!(is_shared_db(&config, "postgresql://user@host/db"));
assert!(!is_shared_db(&config, "/local/path/durable.db"));
}
#[tokio::test]
async fn load_write_cipher_rejects_disabled_encryption_for_postgres_url_scheme() {
let mut config = Config::default();
config.durable.encrypt_payload = false;
config.memory.sqlite_path = "postgres://user@host/db".to_owned();
assert!(
load_write_cipher(&config).is_err(),
"a postgres:// resolved URL must be treated as shared even without shared_db=true"
);
}
#[tokio::test]
async fn load_write_hmac_key_returns_none_for_single_user_local() {
let config = Config::default();
assert!(!config.durable.shared_db);
assert!(
load_write_hmac_key(&config).unwrap().is_none(),
"single-user local, non-shared database must not resolve an HMAC key"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_write_hmac_key_fails_closed_when_shared_db_declared_and_key_missing() {
let mut config = Config::default();
config.durable.shared_db = true;
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let result = load_write_hmac_key(&config);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_err(),
"a declared shared database must fail closed without ZEPH_DURABLE_KEY (INV-8)"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_write_hmac_key_resolves_real_key_when_shared_db_declared() {
let mut config = Config::default();
config.durable.shared_db = true;
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let result = load_write_hmac_key(&config);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.unwrap().is_some(),
"a declared shared database with a real vault key must resolve an HMAC key"
);
}
}