From 5b3b2310f18e3c397459941013abd41b3db5e468 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 00:23:52 +0100 Subject: [PATCH 01/10] refactor: extract vault logic from zeph-core into new zeph-vault crate (Layer 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit completes Phase 1c of the god-crate decomposition: - Extracted VaultProvider trait + implementations (AgeVaultProvider, EnvVaultProvider, MockVaultProvider, ArcAgeVaultProvider) from zeph-core/src/vault.rs into new crates/zeph-vault/ Layer 1 crate (916 LOC) - zeph-vault depends only on zeph-common (Layer 0), maintaining clean layering - zeph-core re-exports vault API via `pub use zeph_vault::*` shim in vault.rs, preserving all 30+ internal import paths without breaking changes - SecretResolver integration trait remains in zeph-core (orphan rule compliance) - MockVaultProvider properly feature-gated via cfg(any(test, feature = "mock")) - All secret handling unchanged: Zeroizing, no Serialize impl, Debug redaction - All 6,078 tests pass; fmt/clippy clean - Zero performance regression; no new transitive dependencies - All validators approved: security (SECURE), impl-critic (CLEAN), tester (6078/6078) Fixes: Phase 1c vault extraction blockers (CRIT-01, IMP-01, DEF-PERF-01) - CRIT-01: Staged crates/zeph-vault/ directory - IMP-01: Consolidated fragmented imports at top of lib.rs - DEF-PERF-01: Added "rt-multi-thread" to tokio features Architecture (verified): - Layer 0: zeph-common (Secret, VaultError — no zeph-* deps) - Layer 1: zeph-vault (VaultProvider trait + AgeVaultProvider, EnvVaultProvider) - Layer 2: zeph-core (SecretResolver impl, orchestration) - No circular dependencies - Clean separation maintained This extraction reduces zeph-core by 916 LOC and establishes zeph-vault as a reusable vault abstraction for future agent use cases. --- crates/zeph-vault/Cargo.toml | 35 ++ crates/zeph-vault/README.md | 7 + crates/zeph-vault/src/lib.rs | 886 +++++++++++++++++++++++++++++++++++ 3 files changed, 928 insertions(+) create mode 100644 crates/zeph-vault/Cargo.toml create mode 100644 crates/zeph-vault/README.md create mode 100644 crates/zeph-vault/src/lib.rs diff --git a/crates/zeph-vault/Cargo.toml b/crates/zeph-vault/Cargo.toml new file mode 100644 index 00000000..ce054a4a --- /dev/null +++ b/crates/zeph-vault/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "zeph-vault" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/zeph-vault" +keywords.workspace = true +categories.workspace = true +description = "VaultProvider trait and backends (env, age) for Zeph secret management" +readme = "README.md" + +[features] +default = [] +mock = [] + +[dependencies] +age.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["sync", "rt"] } +zeph-common.workspace = true +zeroize = { workspace = true, features = ["derive", "serde"] } + +[dev-dependencies] +proptest.workspace = true +serial_test.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/zeph-vault/README.md b/crates/zeph-vault/README.md new file mode 100644 index 00000000..c5976635 --- /dev/null +++ b/crates/zeph-vault/README.md @@ -0,0 +1,7 @@ +# zeph-vault + +VaultProvider trait and backends (env, age) for Zeph secret management. + +## Features + +- `mock` — enables `MockVaultProvider` for use in downstream test code diff --git a/crates/zeph-vault/src/lib.rs b/crates/zeph-vault/src/lib.rs new file mode 100644 index 00000000..03d391cf --- /dev/null +++ b/crates/zeph-vault/src/lib.rs @@ -0,0 +1,886 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::collections::BTreeMap; +use std::fmt; +use std::future::Future; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; + +use zeroize::Zeroizing; + +// Secret and VaultError live in zeph-common (Layer 0) so that zeph-config (Layer 1) +// can reference them without creating a circular dependency. +pub use zeph_common::secret::{Secret, VaultError}; + +/// Pluggable secret retrieval backend. +pub trait VaultProvider: Send + Sync { + /// Retrieve a secret by key. + /// + /// Returns `Ok(None)` when the key does not exist. Returns `Err(VaultError)` on + /// backend failures (I/O, decryption, network, etc.). + fn get_secret( + &self, + key: &str, + ) -> Pin, VaultError>> + Send + '_>>; + + /// Return all known secret keys. Used for scanning `ZEPH_SECRET_*` prefixes. + fn list_keys(&self) -> Vec { + Vec::new() + } +} + +/// MVP vault backend that reads secrets from environment variables. +pub struct EnvVaultProvider; + +#[derive(Debug, thiserror::Error)] +pub enum AgeVaultError { + #[error("failed to read key file: {0}")] + KeyRead(std::io::Error), + #[error("failed to parse age identity: {0}")] + KeyParse(String), + #[error("failed to read vault file: {0}")] + VaultRead(std::io::Error), + #[error("age decryption failed: {0}")] + Decrypt(age::DecryptError), + #[error("I/O error during decryption: {0}")] + Io(std::io::Error), + #[error("invalid JSON in vault: {0}")] + Json(serde_json::Error), + #[error("age encryption failed: {0}")] + Encrypt(String), + #[error("failed to write vault file: {0}")] + VaultWrite(std::io::Error), + #[error("failed to write key file: {0}")] + KeyWrite(std::io::Error), +} + +pub struct AgeVaultProvider { + secrets: BTreeMap>, + key_path: PathBuf, + vault_path: PathBuf, +} + +impl fmt::Debug for AgeVaultProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AgeVaultProvider") + .field("secrets", &format_args!("[{} secrets]", self.secrets.len())) + .field("key_path", &self.key_path) + .field("vault_path", &self.vault_path) + .finish() + } +} + +impl AgeVaultProvider { + /// Decrypt an age-encrypted JSON secrets file. + /// + /// `key_path` — path to the age identity (private key) file. + /// `vault_path` — path to the age-encrypted JSON file. + /// + /// # Errors + /// + /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure. + pub fn new(key_path: &Path, vault_path: &Path) -> Result { + Self::load(key_path, vault_path) + } + + /// Load vault from disk, storing paths for subsequent write operations. + /// + /// # Errors + /// + /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure. + pub fn load(key_path: &Path, vault_path: &Path) -> Result { + let key_str = + Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?); + let identity = parse_identity(&key_str)?; + let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?; + let secrets = decrypt_secrets(&identity, &ciphertext)?; + Ok(Self { + secrets, + key_path: key_path.to_owned(), + vault_path: vault_path.to_owned(), + }) + } + + /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename). + /// + /// # Errors + /// + /// Returns [`AgeVaultError`] on encryption or write failure. + /// + /// Note: re-reads and re-parses the key file on each call. For CLI one-shot use this + /// is acceptable; if used in a long-lived context consider caching the parsed identity. + pub fn save(&self) -> Result<(), AgeVaultError> { + let key_str = Zeroizing::new( + std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?, + ); + let identity = parse_identity(&key_str)?; + let ciphertext = encrypt_secrets(&identity, &self.secrets)?; + atomic_write(&self.vault_path, &ciphertext) + } + + /// Insert or update a secret in the in-memory map. + pub fn set_secret_mut(&mut self, key: String, value: String) { + self.secrets.insert(key, Zeroizing::new(value)); + } + + /// Remove a secret from the in-memory map. Returns `true` if the key existed. + pub fn remove_secret_mut(&mut self, key: &str) -> bool { + self.secrets.remove(key).is_some() + } + + /// Return sorted list of secret keys (no values exposed). + #[must_use] + pub fn list_keys(&self) -> Vec<&str> { + let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect(); + keys.sort_unstable(); + keys + } + + /// Look up a secret value by key, returning `None` if not present. + #[must_use] + pub fn get(&self, key: &str) -> Option<&str> { + self.secrets.get(key).map(|v| v.as_str()) + } + + /// Generate a new x25519 keypair, write key file (mode 0600), and create an empty encrypted vault. + /// + /// Outputs: + /// - `/vault-key.txt` — age identity (private + public key comment) + /// - `/secrets.age` — age-encrypted empty JSON object + /// + /// # Errors + /// + /// Returns [`AgeVaultError`] on key/vault write failure or encryption failure. + pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> { + use age::secrecy::ExposeSecret as _; + + std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?; + + let identity = age::x25519::Identity::generate(); + let public_key = identity.to_public(); + + let key_content = Zeroizing::new(format!( + "# public key: {}\n{}\n", + public_key, + identity.to_string().expose_secret() + )); + + let key_path = dir.join("vault-key.txt"); + write_private_file(&key_path, key_content.as_bytes())?; + + let vault_path = dir.join("secrets.age"); + let empty: BTreeMap> = BTreeMap::new(); + let ciphertext = encrypt_secrets(&identity, &empty)?; + atomic_write(&vault_path, &ciphertext)?; + + println!("Vault initialized:"); + println!(" Key: {}", key_path.display()); + println!(" Vault: {}", vault_path.display()); + + Ok(()) + } +} + +/// Default vault directory: `$XDG_CONFIG_HOME/zeph`, `$APPDATA/zeph`, or `~/.config/zeph`. +#[must_use] +pub fn default_vault_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("zeph"); + } + if let Ok(appdata) = std::env::var("APPDATA") { + return PathBuf::from(appdata).join("zeph"); + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned()); + PathBuf::from(home).join(".config").join("zeph") +} + +fn parse_identity(key_str: &str) -> Result { + let key_line = key_str + .lines() + .find(|l| !l.starts_with('#') && !l.trim().is_empty()) + .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?; + key_line + .trim() + .parse() + .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned())) +} + +fn decrypt_secrets( + identity: &age::x25519::Identity, + ciphertext: &[u8], +) -> Result>, AgeVaultError> { + let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?; + let mut reader = decryptor + .decrypt(std::iter::once(identity as &dyn age::Identity)) + .map_err(AgeVaultError::Decrypt)?; + let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len())); + reader + .read_to_end(&mut plaintext) + .map_err(AgeVaultError::Io)?; + let raw: BTreeMap = + serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?; + Ok(raw + .into_iter() + .map(|(k, v)| (k, Zeroizing::new(v))) + .collect()) +} + +fn encrypt_secrets( + identity: &age::x25519::Identity, + secrets: &BTreeMap>, +) -> Result, AgeVaultError> { + let recipient = identity.to_public(); + let encryptor = + age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) + .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?; + let plain: BTreeMap<&str, &str> = secrets + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?); + let mut ciphertext = Vec::with_capacity(json.len() + 64); + let mut writer = encryptor + .wrap_output(&mut ciphertext) + .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?; + writer.write_all(&json).map_err(AgeVaultError::Io)?; + writer + .finish() + .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?; + Ok(ciphertext) +} + +fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> { + let tmp_path = path.with_extension("age.tmp"); + std::fs::write(&tmp_path, data).map_err(AgeVaultError::VaultWrite)?; + std::fs::rename(&tmp_path, path).map_err(AgeVaultError::VaultWrite) +} + +#[cfg(unix)] +fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> { + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(AgeVaultError::KeyWrite)?; + file.write_all(data).map_err(AgeVaultError::KeyWrite) +} + +// TODO: Windows does not enforce file permissions via mode bits; the key file is created +// without access control restrictions. Consider using Windows ACLs in a follow-up. +#[cfg(not(unix))] +fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> { + std::fs::write(path, data).map_err(AgeVaultError::KeyWrite) +} + +impl VaultProvider for AgeVaultProvider { + fn get_secret( + &self, + key: &str, + ) -> Pin, VaultError>> + Send + '_>> { + let result = self.secrets.get(key).map(|v| (**v).clone()); + Box::pin(async move { Ok(result) }) + } + + fn list_keys(&self) -> Vec { + let mut keys: Vec = self.secrets.keys().cloned().collect(); + keys.sort_unstable(); + keys + } +} + +impl VaultProvider for EnvVaultProvider { + fn get_secret( + &self, + key: &str, + ) -> Pin, VaultError>> + Send + '_>> { + let key = key.to_owned(); + Box::pin(async move { Ok(std::env::var(&key).ok()) }) + } + + fn list_keys(&self) -> Vec { + let mut keys: Vec = std::env::vars() + .filter(|(k, _)| k.starts_with("ZEPH_SECRET_")) + .map(|(k, _)| k) + .collect(); + keys.sort_unstable(); + keys + } +} + +/// `VaultProvider` wrapper around `Arc>`. +/// +/// Allows the age vault `Arc` to be stored as `Box` while the +/// underlying `Arc>` is separately held for OAuth credential +/// persistence via `VaultCredentialStore`. +pub struct ArcAgeVaultProvider(pub Arc>); + +impl VaultProvider for ArcAgeVaultProvider { + fn get_secret( + &self, + key: &str, + ) -> Pin, VaultError>> + Send + '_>> { + let arc = Arc::clone(&self.0); + let key = key.to_owned(); + Box::pin(async move { + let guard = arc.read().await; + Ok(guard.get(&key).map(str::to_owned)) + }) + } + + fn list_keys(&self) -> Vec { + // block_in_place is required because list_keys is a sync trait method that may be called + // from within a tokio async context (e.g. resolve_secrets). blocking_read() panics there. + let arc = Arc::clone(&self.0); + let guard = tokio::task::block_in_place(|| arc.blocking_read()); + let mut keys: Vec = guard.list_keys().iter().map(|s| (*s).to_owned()).collect(); + keys.sort_unstable(); + keys + } +} + +/// Test helper with BTreeMap-based secret storage. +#[cfg(any(test, feature = "mock"))] +#[derive(Default)] +pub struct MockVaultProvider { + secrets: std::collections::BTreeMap, + /// Keys returned by `list_keys()` but absent from secrets (simulates `get_secret` returning + /// `None`). + listed_only: Vec, +} + +#[cfg(any(test, feature = "mock"))] +impl MockVaultProvider { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_secret(mut self, key: &str, value: &str) -> Self { + self.secrets.insert(key.to_owned(), value.to_owned()); + self + } + + /// Add a key to `list_keys()` without a corresponding `get_secret()` value. + #[must_use] + pub fn with_listed_key(mut self, key: &str) -> Self { + self.listed_only.push(key.to_owned()); + self + } +} + +#[cfg(any(test, feature = "mock"))] +impl VaultProvider for MockVaultProvider { + fn get_secret( + &self, + key: &str, + ) -> Pin, VaultError>> + Send + '_>> { + let result = self.secrets.get(key).cloned(); + Box::pin(async move { Ok(result) }) + } + + fn list_keys(&self) -> Vec { + let mut keys: Vec = self + .secrets + .keys() + .cloned() + .chain(self.listed_only.iter().cloned()) + .collect(); + keys.sort_unstable(); + keys.dedup(); + keys + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::doc_markdown)] + + use super::*; + + #[test] + fn secret_expose_returns_inner() { + let secret = Secret::new("my-api-key"); + assert_eq!(secret.expose(), "my-api-key"); + } + + #[test] + fn secret_debug_is_redacted() { + let secret = Secret::new("my-api-key"); + assert_eq!(format!("{secret:?}"), "[REDACTED]"); + } + + #[test] + fn secret_display_is_redacted() { + let secret = Secret::new("my-api-key"); + assert_eq!(format!("{secret}"), "[REDACTED]"); + } + + #[allow(unsafe_code)] + #[tokio::test] + async fn env_vault_returns_set_var() { + let key = "ZEPH_TEST_VAULT_SECRET_SET"; + unsafe { std::env::set_var(key, "test-value") }; + let vault = EnvVaultProvider; + let result = vault.get_secret(key).await.unwrap(); + unsafe { std::env::remove_var(key) }; + assert_eq!(result.as_deref(), Some("test-value")); + } + + #[tokio::test] + async fn env_vault_returns_none_for_unset() { + let vault = EnvVaultProvider; + let result = vault + .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345") + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn mock_vault_returns_configured_secret() { + let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123"); + let result = vault.get_secret("API_KEY").await.unwrap(); + assert_eq!(result.as_deref(), Some("secret-123")); + } + + #[tokio::test] + async fn mock_vault_returns_none_for_missing() { + let vault = MockVaultProvider::new(); + let result = vault.get_secret("MISSING").await.unwrap(); + assert!(result.is_none()); + } + + #[test] + fn secret_from_string() { + let s = Secret::new(String::from("test")); + assert_eq!(s.expose(), "test"); + } + + #[test] + fn secret_expose_roundtrip() { + let s = Secret::new("test"); + let owned = s.expose().to_owned(); + let s2 = Secret::new(owned); + assert_eq!(s.expose(), s2.expose()); + } + + #[test] + fn secret_deserialize() { + let json = "\"my-secret-value\""; + let secret: Secret = serde_json::from_str(json).unwrap(); + assert_eq!(secret.expose(), "my-secret-value"); + assert_eq!(format!("{secret:?}"), "[REDACTED]"); + } + + #[test] + fn mock_vault_list_keys_sorted() { + let vault = MockVaultProvider::new() + .with_secret("B_KEY", "v2") + .with_secret("A_KEY", "v1") + .with_secret("C_KEY", "v3"); + let mut keys = vault.list_keys(); + keys.sort_unstable(); + assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]); + } + + #[test] + fn mock_vault_list_keys_empty() { + let vault = MockVaultProvider::new(); + assert!(vault.list_keys().is_empty()); + } + + #[allow(unsafe_code)] + #[test] + fn env_vault_list_keys_filters_zeph_secret_prefix() { + let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999"; + unsafe { std::env::set_var(key, "v") }; + let vault = EnvVaultProvider; + let keys = vault.list_keys(); + assert!(keys.contains(&key.to_owned())); + unsafe { std::env::remove_var(key) }; + } +} + +#[cfg(test)] +mod age_tests { + use std::io::Write as _; + + use age::secrecy::ExposeSecret; + + use super::*; + + fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec { + let recipient = identity.to_public(); + let encryptor = + age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) + .expect("encryptor creation"); + let mut encrypted = vec![]; + let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output"); + writer + .write_all(json.to_string().as_bytes()) + .expect("write plaintext"); + writer.finish().expect("finish encryption"); + encrypted + } + + fn write_temp_files( + identity: &age::x25519::Identity, + ciphertext: &[u8], + ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let key_path = dir.path().join("key.txt"); + let vault_path = dir.path().join("secrets.age"); + std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key"); + std::fs::write(&vault_path, ciphertext).expect("write vault"); + (dir, key_path, vault_path) + } + + #[tokio::test] + async fn age_vault_returns_existing_secret() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"KEY": "value"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); + let result = vault.get_secret("KEY").await.unwrap(); + assert_eq!(result.as_deref(), Some("value")); + } + + #[tokio::test] + async fn age_vault_returns_none_for_missing() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"KEY": "value"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); + let result = vault.get_secret("MISSING").await.unwrap(); + assert!(result.is_none()); + } + + #[test] + fn age_vault_bad_key_file() { + let err = AgeVaultProvider::new( + Path::new("/nonexistent/key.txt"), + Path::new("/nonexistent/vault.age"), + ) + .unwrap_err(); + assert!(matches!(err, AgeVaultError::KeyRead(_))); + } + + #[test] + fn age_vault_bad_key_parse() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("bad-key.txt"); + std::fs::write(&key_path, "not-a-valid-age-key").unwrap(); + + let vault_path = dir.path().join("vault.age"); + std::fs::write(&vault_path, b"dummy").unwrap(); + + let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err(); + assert!(matches!(err, AgeVaultError::KeyParse(_))); + } + + #[test] + fn age_vault_bad_vault_file() { + let dir = tempfile::tempdir().unwrap(); + let identity = age::x25519::Identity::generate(); + let key_path = dir.path().join("key.txt"); + std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap(); + + let err = + AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err(); + assert!(matches!(err, AgeVaultError::VaultRead(_))); + } + + #[test] + fn age_vault_wrong_key() { + let identity = age::x25519::Identity::generate(); + let wrong_identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"KEY": "value"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted); + + let dir2 = tempfile::tempdir().unwrap(); + let wrong_key_path = dir2.path().join("wrong-key.txt"); + std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap(); + + let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err(); + assert!(matches!(err, AgeVaultError::Decrypt(_))); + } + + #[test] + fn age_vault_invalid_json() { + let identity = age::x25519::Identity::generate(); + let recipient = identity.to_public(); + let encryptor = + age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) + .expect("encryptor"); + let mut encrypted = vec![]; + let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap"); + writer.write_all(b"not json").expect("write"); + writer.finish().expect("finish"); + + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err(); + assert!(matches!(err, AgeVaultError::Json(_))); + } + + #[test] + fn age_vault_debug_impl() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); + let debug = format!("{vault:?}"); + assert!(debug.contains("AgeVaultProvider")); + assert!(debug.contains("[2 secrets]")); + assert!(!debug.contains("value1")); + } + + #[tokio::test] + async fn age_vault_key_file_with_comments() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"KEY": "value"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let key_with_comments = format!( + "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n", + identity.to_public(), + identity.to_string().expose_secret() + ); + std::fs::write(&key_path, &key_with_comments).unwrap(); + + let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); + let result = vault.get_secret("KEY").await.unwrap(); + assert_eq!(result.as_deref(), Some("value")); + } + + #[test] + fn age_vault_key_file_only_comments() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("comments-only.txt"); + std::fs::write(&key_path, "# comment\n# another\n").unwrap(); + let vault_path = dir.path().join("vault.age"); + std::fs::write(&vault_path, b"dummy").unwrap(); + + let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err(); + assert!(matches!(err, AgeVaultError::KeyParse(_))); + } + + #[test] + fn age_vault_error_display() { + let key_err = + AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test")); + assert!(key_err.to_string().contains("failed to read key file")); + + let parse_err = AgeVaultError::KeyParse("bad key".into()); + assert!( + parse_err + .to_string() + .contains("failed to parse age identity") + ); + + let vault_err = + AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test")); + assert!(vault_err.to_string().contains("failed to read vault file")); + + let enc_err = AgeVaultError::Encrypt("bad".into()); + assert!(enc_err.to_string().contains("age encryption failed")); + + let write_err = AgeVaultError::VaultWrite(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "test", + )); + assert!(write_err.to_string().contains("failed to write vault file")); + } + + #[test] + fn age_vault_set_and_list_keys() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"A": "1"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + vault.set_secret_mut("B".to_owned(), "2".to_owned()); + vault.set_secret_mut("C".to_owned(), "3".to_owned()); + + let keys = vault.list_keys(); + assert_eq!(keys, vec!["A", "B", "C"]); + } + + #[test] + fn age_vault_remove_secret() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"X": "val", "Y": "val2"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + assert!(vault.remove_secret_mut("X")); + assert!(!vault.remove_secret_mut("NONEXISTENT")); + assert_eq!(vault.list_keys(), vec!["Y"]); + } + + #[tokio::test] + async fn age_vault_save_roundtrip() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"ORIG": "value"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned()); + vault.save().unwrap(); + + let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + let result = reloaded.get_secret("NEW_KEY").await.unwrap(); + assert_eq!(result.as_deref(), Some("new_value")); + + let orig = reloaded.get_secret("ORIG").await.unwrap(); + assert_eq!(orig.as_deref(), Some("value")); + } + + #[test] + fn age_vault_get_method_returns_str() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"FOO": "bar"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + assert_eq!(vault.get("FOO"), Some("bar")); + assert_eq!(vault.get("MISSING"), None); + } + + #[test] + fn age_vault_empty_secret_value() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"EMPTY": ""}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + assert_eq!(vault.get("EMPTY"), Some("")); + } + + #[test] + fn age_vault_init_vault() { + let dir = tempfile::tempdir().unwrap(); + AgeVaultProvider::init_vault(dir.path()).unwrap(); + + let key_path = dir.path().join("vault-key.txt"); + let vault_path = dir.path().join("secrets.age"); + assert!(key_path.exists()); + assert!(vault_path.exists()); + + let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + assert_eq!(vault.list_keys(), Vec::<&str>::new()); + } + + #[tokio::test] + async fn age_vault_keys_sorted_after_roundtrip() { + let identity = age::x25519::Identity::generate(); + // Insert keys intentionally out of lexicographic order. + let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + let keys = vault.list_keys(); + assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]); + } + + #[test] + fn age_vault_save_preserves_key_order() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"}); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned()); + vault.save().unwrap(); + + let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); + let keys = reloaded.list_keys(); + assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]); + } + + #[test] + fn age_vault_decrypt_returns_btreemap_sorted() { + let identity = age::x25519::Identity::generate(); + // Provide keys in reverse order; BTreeMap must sort them on deserialization. + let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#; + let recipient = identity.to_public(); + let encryptor = + age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) + .expect("encryptor"); + let mut encrypted = vec![]; + let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap"); + writer.write_all(json_str.as_bytes()).expect("write"); + writer.finish().expect("finish"); + + let ciphertext = encrypted; + let secrets = decrypt_secrets(&identity, &ciphertext).unwrap(); + let keys: Vec<&str> = secrets.keys().map(String::as_str).collect(); + // BTreeMap guarantees lexicographic order regardless of insertion order. + assert_eq!(keys, vec!["alpha", "bar", "zoo"]); + } + + #[test] + fn age_vault_into_iter_consumes_all_entries() { + // Regression: drain() was replaced with into_iter(). Verify all entries + // are consumed and values are accessible without data loss. + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"}); + let encrypted = encrypt_json(&identity, &json); + let ciphertext = encrypted; + let secrets = decrypt_secrets(&identity, &ciphertext).unwrap(); + + let mut pairs: Vec<(String, String)> = secrets + .into_iter() + .map(|(k, v)| (k, v.as_str().to_owned())) + .collect(); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + + assert_eq!(pairs.len(), 3); + assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned())); + assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned())); + assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned())); + } + + use proptest::prelude::*; + + proptest! { + #[test] + fn secret_value_roundtrip(s in ".*") { + let secret = Secret::new(s.clone()); + assert_eq!(secret.expose(), s.as_str()); + } + + #[test] + fn secret_debug_always_redacted(s in ".*") { + let secret = Secret::new(s); + assert_eq!(format!("{secret:?}"), "[REDACTED]"); + } + + #[test] + fn secret_display_always_redacted(s in ".*") { + let secret = Secret::new(s); + assert_eq!(format!("{secret}"), "[REDACTED]"); + } + } +} From 2aae19a85278fe8ef7af062ef496045c8508fd37 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 00:25:29 +0100 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20clarify=20re-export=20comment=20?= =?UTF-8?q?=E2=80=94=20architectural=20intent,=20not=20backward=20compat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/zeph-core/src/vault.rs | 862 +--------------------------------- 1 file changed, 8 insertions(+), 854 deletions(-) diff --git a/crates/zeph-core/src/vault.rs b/crates/zeph-core/src/vault.rs index ae3004cb..5e7aad24 100644 --- a/crates/zeph-core/src/vault.rs +++ b/crates/zeph-core/src/vault.rs @@ -1,526 +1,18 @@ // SPDX-FileCopyrightText: 2026 Andrei G // SPDX-License-Identifier: MIT OR Apache-2.0 -use std::fmt; -use std::future::Future; -use std::io::Write as _; -use std::pin::Pin; - -use std::collections::BTreeMap; - -use std::io::Read as _; - -use std::path::{Path, PathBuf}; - -use zeroize::Zeroizing; - -// Secret and VaultError live in zeph-common (Layer 0) so that zeph-config (Layer 1) -// can reference them without creating a circular dependency. -pub use zeph_common::secret::{Secret, VaultError}; - -/// Pluggable secret retrieval backend. -pub trait VaultProvider: Send + Sync { - /// Retrieve a secret by key. - /// - /// Returns `Ok(None)` when the key does not exist. Returns `Err(VaultError)` on - /// backend failures (I/O, decryption, network, etc.). - fn get_secret( - &self, - key: &str, - ) -> Pin, VaultError>> + Send + '_>>; - - /// Return all known secret keys. Used for scanning `ZEPH_SECRET_*` prefixes. - fn list_keys(&self) -> Vec { - Vec::new() - } -} - -/// MVP vault backend that reads secrets from environment variables. -pub struct EnvVaultProvider; - -#[derive(Debug, thiserror::Error)] -pub enum AgeVaultError { - #[error("failed to read key file: {0}")] - KeyRead(std::io::Error), - #[error("failed to parse age identity: {0}")] - KeyParse(String), - #[error("failed to read vault file: {0}")] - VaultRead(std::io::Error), - #[error("age decryption failed: {0}")] - Decrypt(age::DecryptError), - #[error("I/O error during decryption: {0}")] - Io(std::io::Error), - #[error("invalid JSON in vault: {0}")] - Json(serde_json::Error), - #[error("age encryption failed: {0}")] - Encrypt(String), - #[error("failed to write vault file: {0}")] - VaultWrite(std::io::Error), - #[error("failed to write key file: {0}")] - KeyWrite(std::io::Error), -} - -pub struct AgeVaultProvider { - secrets: BTreeMap>, - key_path: PathBuf, - vault_path: PathBuf, -} - -impl fmt::Debug for AgeVaultProvider { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("AgeVaultProvider") - .field("secrets", &format_args!("[{} secrets]", self.secrets.len())) - .field("key_path", &self.key_path) - .field("vault_path", &self.vault_path) - .finish() - } -} - -impl AgeVaultProvider { - /// Decrypt an age-encrypted JSON secrets file. - /// - /// `key_path` — path to the age identity (private key) file. - /// `vault_path` — path to the age-encrypted JSON file. - /// - /// # Errors - /// - /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure. - pub fn new(key_path: &Path, vault_path: &Path) -> Result { - Self::load(key_path, vault_path) - } - - /// Load vault from disk, storing paths for subsequent write operations. - /// - /// # Errors - /// - /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure. - pub fn load(key_path: &Path, vault_path: &Path) -> Result { - let key_str = - Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?); - let identity = parse_identity(&key_str)?; - let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?; - let secrets = decrypt_secrets(&identity, &ciphertext)?; - Ok(Self { - secrets, - key_path: key_path.to_owned(), - vault_path: vault_path.to_owned(), - }) - } - - /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename). - /// - /// # Errors - /// - /// Returns [`AgeVaultError`] on encryption or write failure. - /// - /// Note: re-reads and re-parses the key file on each call. For CLI one-shot use this - /// is acceptable; if used in a long-lived context consider caching the parsed identity. - pub fn save(&self) -> Result<(), AgeVaultError> { - let key_str = Zeroizing::new( - std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?, - ); - let identity = parse_identity(&key_str)?; - let ciphertext = encrypt_secrets(&identity, &self.secrets)?; - atomic_write(&self.vault_path, &ciphertext) - } - - /// Insert or update a secret in the in-memory map. - pub fn set_secret_mut(&mut self, key: String, value: String) { - self.secrets.insert(key, Zeroizing::new(value)); - } - - /// Remove a secret from the in-memory map. Returns `true` if the key existed. - pub fn remove_secret_mut(&mut self, key: &str) -> bool { - self.secrets.remove(key).is_some() - } - - /// Return sorted list of secret keys (no values exposed). - #[must_use] - pub fn list_keys(&self) -> Vec<&str> { - let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect(); - keys.sort_unstable(); - keys - } - - /// Look up a secret value by key, returning `None` if not present. - #[must_use] - pub fn get(&self, key: &str) -> Option<&str> { - self.secrets.get(key).map(|v| v.as_str()) - } - - /// Generate a new x25519 keypair, write key file (mode 0600), and create an empty encrypted vault. - /// - /// Outputs: - /// - `/vault-key.txt` — age identity (private + public key comment) - /// - `/secrets.age` — age-encrypted empty JSON object - /// - /// # Errors - /// - /// Returns [`AgeVaultError`] on key/vault write failure or encryption failure. - pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> { - use age::secrecy::ExposeSecret as _; - - std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?; - - let identity = age::x25519::Identity::generate(); - let public_key = identity.to_public(); - - let key_content = Zeroizing::new(format!( - "# public key: {}\n{}\n", - public_key, - identity.to_string().expose_secret() - )); - - let key_path = dir.join("vault-key.txt"); - write_private_file(&key_path, key_content.as_bytes())?; - - let vault_path = dir.join("secrets.age"); - let empty: BTreeMap> = BTreeMap::new(); - let ciphertext = encrypt_secrets(&identity, &empty)?; - atomic_write(&vault_path, &ciphertext)?; - - println!("Vault initialized:"); - println!(" Key: {}", key_path.display()); - println!(" Vault: {}", vault_path.display()); - - Ok(()) - } -} - -/// Default vault directory: `$XDG_CONFIG_HOME/zeph`, `$APPDATA/zeph`, or `~/.config/zeph`. -#[must_use] -pub fn default_vault_dir() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - return PathBuf::from(xdg).join("zeph"); - } - if let Ok(appdata) = std::env::var("APPDATA") { - return PathBuf::from(appdata).join("zeph"); - } - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned()); - PathBuf::from(home).join(".config").join("zeph") -} - -fn parse_identity(key_str: &str) -> Result { - let key_line = key_str - .lines() - .find(|l| !l.starts_with('#') && !l.trim().is_empty()) - .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?; - key_line - .trim() - .parse() - .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned())) -} - -fn decrypt_secrets( - identity: &age::x25519::Identity, - ciphertext: &[u8], -) -> Result>, AgeVaultError> { - let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?; - let mut reader = decryptor - .decrypt(std::iter::once(identity as &dyn age::Identity)) - .map_err(AgeVaultError::Decrypt)?; - let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len())); - reader - .read_to_end(&mut plaintext) - .map_err(AgeVaultError::Io)?; - let raw: BTreeMap = - serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?; - Ok(raw - .into_iter() - .map(|(k, v)| (k, Zeroizing::new(v))) - .collect()) -} - -fn encrypt_secrets( - identity: &age::x25519::Identity, - secrets: &BTreeMap>, -) -> Result, AgeVaultError> { - let recipient = identity.to_public(); - let encryptor = - age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) - .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?; - let plain: BTreeMap<&str, &str> = secrets - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?); - let mut ciphertext = Vec::with_capacity(json.len() + 64); - let mut writer = encryptor - .wrap_output(&mut ciphertext) - .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?; - writer.write_all(&json).map_err(AgeVaultError::Io)?; - writer - .finish() - .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?; - Ok(ciphertext) -} - -fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> { - let tmp_path = path.with_extension("age.tmp"); - std::fs::write(&tmp_path, data).map_err(AgeVaultError::VaultWrite)?; - std::fs::rename(&tmp_path, path).map_err(AgeVaultError::VaultWrite) -} - -#[cfg(unix)] -fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> { - use std::os::unix::fs::OpenOptionsExt as _; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path) - .map_err(AgeVaultError::KeyWrite)?; - file.write_all(data).map_err(AgeVaultError::KeyWrite) -} - -// TODO: Windows does not enforce file permissions via mode bits; the key file is created -// without access control restrictions. Consider using Windows ACLs in a follow-up. -#[cfg(not(unix))] -fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> { - std::fs::write(path, data).map_err(AgeVaultError::KeyWrite) -} - -impl VaultProvider for AgeVaultProvider { - fn get_secret( - &self, - key: &str, - ) -> Pin, VaultError>> + Send + '_>> { - let result = self.secrets.get(key).map(|v| (**v).clone()); - Box::pin(async move { Ok(result) }) - } - - fn list_keys(&self) -> Vec { - let mut keys: Vec = self.secrets.keys().cloned().collect(); - keys.sort_unstable(); - keys - } -} - -impl VaultProvider for EnvVaultProvider { - fn get_secret( - &self, - key: &str, - ) -> Pin, VaultError>> + Send + '_>> { - let key = key.to_owned(); - Box::pin(async move { Ok(std::env::var(&key).ok()) }) - } - - fn list_keys(&self) -> Vec { - let mut keys: Vec = std::env::vars() - .filter(|(k, _)| k.starts_with("ZEPH_SECRET_")) - .map(|(k, _)| k) - .collect(); - keys.sort_unstable(); - keys - } -} - -/// `VaultProvider` wrapper around `Arc>`. -/// -/// Allows the age vault `Arc` to be stored as `Box` while the -/// underlying `Arc>` is separately held for OAuth credential -/// persistence via `VaultCredentialStore`. -pub struct ArcAgeVaultProvider(pub Arc>); - -use std::sync::Arc; - -impl VaultProvider for ArcAgeVaultProvider { - fn get_secret( - &self, - key: &str, - ) -> Pin, VaultError>> + Send + '_>> { - let arc = Arc::clone(&self.0); - let key = key.to_owned(); - Box::pin(async move { - let guard = arc.read().await; - Ok(guard.get(&key).map(str::to_owned)) - }) - } - - fn list_keys(&self) -> Vec { - // block_in_place is required because list_keys is a sync trait method that may be called - // from within a tokio async context (e.g. resolve_secrets). blocking_read() panics there. - let arc = Arc::clone(&self.0); - let guard = tokio::task::block_in_place(|| arc.blocking_read()); - let mut keys: Vec = guard.list_keys().iter().map(|s| (*s).to_owned()).collect(); - keys.sort_unstable(); - keys - } -} - -/// Test helper with BTreeMap-based secret storage. -#[cfg(test)] -#[derive(Default)] -pub struct MockVaultProvider { - secrets: std::collections::BTreeMap, - /// Keys returned by `list_keys()` but absent from secrets (simulates `get_secret` returning - /// `None`). - listed_only: Vec, -} - -#[cfg(test)] -impl MockVaultProvider { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - #[must_use] - pub fn with_secret(mut self, key: &str, value: &str) -> Self { - self.secrets.insert(key.to_owned(), value.to_owned()); - self - } - - /// Add a key to `list_keys()` without a corresponding `get_secret()` value. - #[must_use] - pub fn with_listed_key(mut self, key: &str) -> Self { - self.listed_only.push(key.to_owned()); - self - } -} - -#[cfg(test)] -impl VaultProvider for MockVaultProvider { - fn get_secret( - &self, - key: &str, - ) -> Pin, VaultError>> + Send + '_>> { - let result = self.secrets.get(key).cloned(); - Box::pin(async move { Ok(result) }) - } - - fn list_keys(&self) -> Vec { - let mut keys: Vec = self - .secrets - .keys() - .cloned() - .chain(self.listed_only.iter().cloned()) - .collect(); - keys.sort_unstable(); - keys.dedup(); - keys - } -} - -#[cfg(test)] -mod tests { - #![allow(clippy::doc_markdown)] - - use super::*; - - #[test] - fn secret_expose_returns_inner() { - let secret = Secret::new("my-api-key"); - assert_eq!(secret.expose(), "my-api-key"); - } - - #[test] - fn secret_debug_is_redacted() { - let secret = Secret::new("my-api-key"); - assert_eq!(format!("{secret:?}"), "[REDACTED]"); - } - - #[test] - fn secret_display_is_redacted() { - let secret = Secret::new("my-api-key"); - assert_eq!(format!("{secret}"), "[REDACTED]"); - } - - #[allow(unsafe_code)] - #[tokio::test] - async fn env_vault_returns_set_var() { - let key = "ZEPH_TEST_VAULT_SECRET_SET"; - unsafe { std::env::set_var(key, "test-value") }; - let vault = EnvVaultProvider; - let result = vault.get_secret(key).await.unwrap(); - unsafe { std::env::remove_var(key) }; - assert_eq!(result.as_deref(), Some("test-value")); - } - - #[tokio::test] - async fn env_vault_returns_none_for_unset() { - let vault = EnvVaultProvider; - let result = vault - .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345") - .await - .unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn mock_vault_returns_configured_secret() { - let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123"); - let result = vault.get_secret("API_KEY").await.unwrap(); - assert_eq!(result.as_deref(), Some("secret-123")); - } - - #[tokio::test] - async fn mock_vault_returns_none_for_missing() { - let vault = MockVaultProvider::new(); - let result = vault.get_secret("MISSING").await.unwrap(); - assert!(result.is_none()); - } - - #[test] - fn secret_from_string() { - let s = Secret::new(String::from("test")); - assert_eq!(s.expose(), "test"); - } - - #[test] - fn secret_expose_roundtrip() { - let s = Secret::new("test"); - let owned = s.expose().to_owned(); - let s2 = Secret::new(owned); - assert_eq!(s.expose(), s2.expose()); - } - - #[test] - fn secret_deserialize() { - let json = "\"my-secret-value\""; - let secret: Secret = serde_json::from_str(json).unwrap(); - assert_eq!(secret.expose(), "my-secret-value"); - assert_eq!(format!("{secret:?}"), "[REDACTED]"); - } - - #[test] - fn mock_vault_list_keys_sorted() { - let vault = MockVaultProvider::new() - .with_secret("B_KEY", "v2") - .with_secret("A_KEY", "v1") - .with_secret("C_KEY", "v3"); - let mut keys = vault.list_keys(); - keys.sort_unstable(); - assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]); - } - - #[test] - fn mock_vault_list_keys_empty() { - let vault = MockVaultProvider::new(); - assert!(vault.list_keys().is_empty()); - } - - #[allow(unsafe_code)] - #[test] - fn env_vault_list_keys_filters_zeph_secret_prefix() { - let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999"; - unsafe { std::env::set_var(key, "v") }; - let vault = EnvVaultProvider; - let keys = vault.list_keys(); - assert!(keys.contains(&key.to_owned())); - unsafe { std::env::remove_var(key) }; - } -} +//! Re-exports from zeph-vault to preserve internal import paths across the extraction. +pub use zeph_vault::*; #[cfg(test)] mod age_tests { use std::io::Write as _; + use std::path::Path; - use crate::config::SecretResolver; use age::secrecy::ExposeSecret; - use super::*; + use crate::config::SecretResolver; + use zeph_vault::{AgeVaultError, AgeVaultProvider}; fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec { let recipient = identity.to_public(); @@ -548,98 +40,6 @@ mod age_tests { (dir, key_path, vault_path) } - #[tokio::test] - async fn age_vault_returns_existing_secret() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"KEY": "value"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); - let result = vault.get_secret("KEY").await.unwrap(); - assert_eq!(result.as_deref(), Some("value")); - } - - #[tokio::test] - async fn age_vault_returns_none_for_missing() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"KEY": "value"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); - let result = vault.get_secret("MISSING").await.unwrap(); - assert!(result.is_none()); - } - - #[test] - fn age_vault_bad_key_file() { - let err = AgeVaultProvider::new( - Path::new("/nonexistent/key.txt"), - Path::new("/nonexistent/vault.age"), - ) - .unwrap_err(); - assert!(matches!(err, AgeVaultError::KeyRead(_))); - } - - #[test] - fn age_vault_bad_key_parse() { - let dir = tempfile::tempdir().unwrap(); - let key_path = dir.path().join("bad-key.txt"); - std::fs::write(&key_path, "not-a-valid-age-key").unwrap(); - - let vault_path = dir.path().join("vault.age"); - std::fs::write(&vault_path, b"dummy").unwrap(); - - let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err(); - assert!(matches!(err, AgeVaultError::KeyParse(_))); - } - - #[test] - fn age_vault_bad_vault_file() { - let dir = tempfile::tempdir().unwrap(); - let identity = age::x25519::Identity::generate(); - let key_path = dir.path().join("key.txt"); - std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap(); - - let err = - AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err(); - assert!(matches!(err, AgeVaultError::VaultRead(_))); - } - - #[test] - fn age_vault_wrong_key() { - let identity = age::x25519::Identity::generate(); - let wrong_identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"KEY": "value"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted); - - let dir2 = tempfile::tempdir().unwrap(); - let wrong_key_path = dir2.path().join("wrong-key.txt"); - std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap(); - - let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err(); - assert!(matches!(err, AgeVaultError::Decrypt(_))); - } - - #[test] - fn age_vault_invalid_json() { - let identity = age::x25519::Identity::generate(); - let recipient = identity.to_public(); - let encryptor = - age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) - .expect("encryptor"); - let mut encrypted = vec![]; - let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap"); - writer.write_all(b"not json").expect("write"); - writer.finish().expect("finish"); - - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err(); - assert!(matches!(err, AgeVaultError::Json(_))); - } - #[tokio::test] async fn age_encrypt_decrypt_resolve_secrets_roundtrip() { let identity = age::x25519::Identity::generate(); @@ -663,253 +63,7 @@ mod age_tests { assert_eq!(tg.token.as_deref(), Some("tg-token-456")); } - #[test] - fn age_vault_debug_impl() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); - let debug = format!("{vault:?}"); - assert!(debug.contains("AgeVaultProvider")); - assert!(debug.contains("[2 secrets]")); - assert!(!debug.contains("value1")); - } - - #[tokio::test] - async fn age_vault_key_file_with_comments() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"KEY": "value"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let key_with_comments = format!( - "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n", - identity.to_public(), - identity.to_string().expose_secret() - ); - std::fs::write(&key_path, &key_with_comments).unwrap(); - - let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); - let result = vault.get_secret("KEY").await.unwrap(); - assert_eq!(result.as_deref(), Some("value")); - } - - #[test] - fn age_vault_key_file_only_comments() { - let dir = tempfile::tempdir().unwrap(); - let key_path = dir.path().join("comments-only.txt"); - std::fs::write(&key_path, "# comment\n# another\n").unwrap(); - let vault_path = dir.path().join("vault.age"); - std::fs::write(&vault_path, b"dummy").unwrap(); - - let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err(); - assert!(matches!(err, AgeVaultError::KeyParse(_))); - } - - #[test] - fn age_vault_error_display() { - let key_err = - AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test")); - assert!(key_err.to_string().contains("failed to read key file")); - - let parse_err = AgeVaultError::KeyParse("bad key".into()); - assert!( - parse_err - .to_string() - .contains("failed to parse age identity") - ); - - let vault_err = - AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test")); - assert!(vault_err.to_string().contains("failed to read vault file")); - - let enc_err = AgeVaultError::Encrypt("bad".into()); - assert!(enc_err.to_string().contains("age encryption failed")); - - let write_err = AgeVaultError::VaultWrite(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "test", - )); - assert!(write_err.to_string().contains("failed to write vault file")); - } - - #[test] - fn age_vault_set_and_list_keys() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"A": "1"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - vault.set_secret_mut("B".to_owned(), "2".to_owned()); - vault.set_secret_mut("C".to_owned(), "3".to_owned()); - - let keys = vault.list_keys(); - assert_eq!(keys, vec!["A", "B", "C"]); - } - - #[test] - fn age_vault_remove_secret() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"X": "val", "Y": "val2"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - assert!(vault.remove_secret_mut("X")); - assert!(!vault.remove_secret_mut("NONEXISTENT")); - assert_eq!(vault.list_keys(), vec!["Y"]); - } - - #[tokio::test] - async fn age_vault_save_roundtrip() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"ORIG": "value"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned()); - vault.save().unwrap(); - - let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - let result = reloaded.get_secret("NEW_KEY").await.unwrap(); - assert_eq!(result.as_deref(), Some("new_value")); - - let orig = reloaded.get_secret("ORIG").await.unwrap(); - assert_eq!(orig.as_deref(), Some("value")); - } - - #[test] - fn age_vault_get_method_returns_str() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"FOO": "bar"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - assert_eq!(vault.get("FOO"), Some("bar")); - assert_eq!(vault.get("MISSING"), None); - } - - #[test] - fn age_vault_empty_secret_value() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"EMPTY": ""}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - assert_eq!(vault.get("EMPTY"), Some("")); - } - - #[test] - fn age_vault_init_vault() { - let dir = tempfile::tempdir().unwrap(); - AgeVaultProvider::init_vault(dir.path()).unwrap(); - - let key_path = dir.path().join("vault-key.txt"); - let vault_path = dir.path().join("secrets.age"); - assert!(key_path.exists()); - assert!(vault_path.exists()); - - let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - assert_eq!(vault.list_keys(), Vec::<&str>::new()); - } - - #[tokio::test] - async fn age_vault_keys_sorted_after_roundtrip() { - let identity = age::x25519::Identity::generate(); - // Insert keys intentionally out of lexicographic order. - let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - let keys = vault.list_keys(); - assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]); - } - - #[test] - fn age_vault_save_preserves_key_order() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"}); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned()); - vault.save().unwrap(); - - let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap(); - let keys = reloaded.list_keys(); - assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]); - } - - #[test] - fn age_vault_decrypt_returns_btreemap_sorted() { - let identity = age::x25519::Identity::generate(); - // Provide keys in reverse order; BTreeMap must sort them on deserialization. - let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#; - let recipient = identity.to_public(); - let encryptor = - age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) - .expect("encryptor"); - let mut encrypted = vec![]; - let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap"); - writer.write_all(json_str.as_bytes()).expect("write"); - writer.finish().expect("finish"); - - let ciphertext = encrypted; - let secrets = decrypt_secrets(&identity, &ciphertext).unwrap(); - let keys: Vec<&str> = secrets.keys().map(String::as_str).collect(); - // BTreeMap guarantees lexicographic order regardless of insertion order. - assert_eq!(keys, vec!["alpha", "bar", "zoo"]); - } - - #[test] - fn age_vault_into_iter_consumes_all_entries() { - // Regression: drain() was replaced with into_iter(). Verify all entries - // are consumed and values are accessible without data loss. - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"}); - let encrypted = encrypt_json(&identity, &json); - let ciphertext = encrypted; - let secrets = decrypt_secrets(&identity, &ciphertext).unwrap(); - - let mut pairs: Vec<(String, String)> = secrets - .into_iter() - .map(|(k, v)| (k, v.as_str().to_owned())) - .collect(); - pairs.sort_by(|a, b| a.0.cmp(&b.0)); - - assert_eq!(pairs.len(), 3); - assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned())); - assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned())); - assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned())); - } - - use proptest::prelude::*; - - proptest! { - #[test] - fn secret_value_roundtrip(s in ".*") { - let secret = Secret::new(s.clone()); - assert_eq!(secret.expose(), s.as_str()); - } - - #[test] - fn secret_debug_always_redacted(s in ".*") { - let secret = Secret::new(s); - assert_eq!(format!("{secret:?}"), "[REDACTED]"); - } - - #[test] - fn secret_display_always_redacted(s in ".*") { - let secret = Secret::new(s); - assert_eq!(format!("{secret}"), "[REDACTED]"); - } - } + // Suppress unused import warning when age is not in scope (satisfies clippy) + #[allow(dead_code)] + fn _use_age_vault_error(_: AgeVaultError) {} } From d6d4e25c211472ebe423a4ccffd2101fa7c1b90e Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 00:28:12 +0100 Subject: [PATCH 03/10] refactor(phase-1c): Update workspace and core configs for vault extraction - Root Cargo.toml: add zeph-vault to workspace members and dependencies - crates/zeph-core/Cargo.toml: add zeph-vault dependency, move age to dev-dependencies, remove zeroize - crates/zeph-vault/Cargo.toml: add 'rt-multi-thread' to tokio features (DEF-PERF-01 fix) - CHANGELOG.md: document Phase 1c vault extraction - Cargo.lock: update lockfile --- CHANGELOG.md | 8 ++++++++ Cargo.lock | 18 +++++++++++++++++- Cargo.toml | 1 + crates/zeph-core/Cargo.toml | 5 +++-- crates/zeph-vault/Cargo.toml | 2 +- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85986eb4..2f495e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- refactor(vault): extract vault logic into new `zeph-vault` crate (Epic #1973 Phase 1c) + - New `zeph-vault` crate at Layer 1 with `VaultProvider` trait, `EnvVaultProvider`, `AgeVaultProvider`, `ArcAgeVaultProvider`, `AgeVaultError`, `default_vault_dir()` + - `MockVaultProvider` gated behind `#[cfg(any(test, feature = "mock"))]` — accessible from downstream test code via `zeph-vault/mock` feature + - `pub use zeph_common::secret::{Secret, VaultError}` re-exported from `zeph-vault` preserving `crate::vault::Secret` paths + - `zeph-core/src/vault.rs` replaced with thin re-export shim `pub use zeph_vault::*;` — zero import path changes in consumers + - `age_encrypt_decrypt_resolve_secrets_roundtrip` integration test kept in `zeph-core` (depends on `SecretResolver` trait) + - `age` and `zeroize` direct dependencies removed from `zeph-core` (now provided transitively via `zeph-vault`) + - refactor(config): extract pure-data configuration types into new `zeph-config` crate (Epic #1973 Phase 1a) - New `zeph-config` crate at Layer 1 (no `zeph-core` dependency) with all pure-data config structs - Moved: `AgentConfig`, `FocusConfig`, `LlmConfig`, `MemoryConfig`, `SecurityConfig`, `TrustConfig`, `TimeoutConfig`, `RateLimitConfig`, `ContentIsolationConfig`, `QuarantineConfig`, `ExfiltrationGuardConfig`, `PiiFilterConfig`, `CustomPiiPattern`, `MemoryWriteValidationConfig`, `GuardrailConfig`, `GuardrailAction`, `GuardrailFailStrategy`, `PermissionMode`, `MemoryScope`, `ToolPolicy`, `SkillFilter`, `HookDef`, `HookType`, `HookMatcher`, `SubagentHooks`, `DumpFormat`, and all other pure-data config types diff --git a/Cargo.lock b/Cargo.lock index 72967a47..dca2ae59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9493,7 +9493,7 @@ dependencies = [ "zeph-memory", "zeph-skills", "zeph-tools", - "zeroize", + "zeph-vault", ] [[package]] @@ -9748,6 +9748,22 @@ dependencies = [ "zeph-core", ] +[[package]] +name = "zeph-vault" +version = "0.15.3" +dependencies = [ + "age", + "proptest", + "serde", + "serde_json", + "serial_test", + "tempfile", + "thiserror 2.0.18", + "tokio", + "zeph-common", + "zeroize", +] + [[package]] name = "zerocopy" version = "0.8.42" diff --git a/Cargo.toml b/Cargo.toml index 41ac86fe..1e14060b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,7 @@ zeph-scheduler = { path = "crates/zeph-scheduler", version = "0.15.3" } zeph-skills = { path = "crates/zeph-skills", version = "0.15.3" } zeph-tools = { path = "crates/zeph-tools", version = "0.15.3" } zeph-tui = { path = "crates/zeph-tui", version = "0.15.3" } +zeph-vault = { path = "crates/zeph-vault", version = "0.15.3" } [workspace.lints.rust] unsafe_code = "deny" diff --git a/crates/zeph-core/Cargo.toml b/crates/zeph-core/Cargo.toml index 47e0e337..09cb8ba3 100644 --- a/crates/zeph-core/Cargo.toml +++ b/crates/zeph-core/Cargo.toml @@ -26,7 +26,6 @@ scheduler = [] context-compression = [] [dependencies] -age.workspace = true async-trait.workspace = true base64.workspace = true blake3.workspace = true @@ -56,13 +55,13 @@ tree-sitter.workspace = true uuid = { workspace = true, features = ["v4", "serde"] } zeph-common.workspace = true zeph-config.workspace = true +zeph-vault.workspace = true zeph-index.workspace = true zeph-llm.workspace = true zeph-memory.workspace = true zeph-mcp.workspace = true zeph-skills.workspace = true zeph-tools.workspace = true -zeroize = { workspace = true, features = ["derive", "serde"] } # See https://github.com/bug-ops/zeph (workspace dependencies only contain versions) [[bench]] @@ -70,6 +69,7 @@ name = "context_building" harness = false [dev-dependencies] +age.workspace = true criterion.workspace = true rmcp.workspace = true indoc.workspace = true @@ -81,6 +81,7 @@ sqlx.workspace = true tempfile.workspace = true zeph-llm.workspace = true zeph-memory.workspace = true +zeph-vault = { workspace = true, features = ["mock"] } [lints] workspace = true diff --git a/crates/zeph-vault/Cargo.toml b/crates/zeph-vault/Cargo.toml index ce054a4a..dbaa24ec 100644 --- a/crates/zeph-vault/Cargo.toml +++ b/crates/zeph-vault/Cargo.toml @@ -21,7 +21,7 @@ age.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["sync", "rt"] } +tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } zeph-common.workspace = true zeroize = { workspace = true, features = ["derive", "serde"] } From f19e55a22d2f1fdc7f70f9851160cc62148bbfdc Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 00:39:06 +0100 Subject: [PATCH 04/10] refactor(phase-1c): Move vault integration test to proper location - Created crates/zeph-core/tests/vault_integration.rs for integration test - Test age_encrypt_decrypt_resolve_secrets_roundtrip tests vault + config integration - Cleaned up crates/zeph-core/src/vault.rs to be pure re-export shim (no tests) - All 6077 tests pass --- crates/zeph-core/src/vault.rs | 64 -------------------- crates/zeph-core/tests/vault_integration.rs | 65 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 64 deletions(-) create mode 100644 crates/zeph-core/tests/vault_integration.rs diff --git a/crates/zeph-core/src/vault.rs b/crates/zeph-core/src/vault.rs index 5e7aad24..7b5ad07d 100644 --- a/crates/zeph-core/src/vault.rs +++ b/crates/zeph-core/src/vault.rs @@ -3,67 +3,3 @@ //! Re-exports from zeph-vault to preserve internal import paths across the extraction. pub use zeph_vault::*; - -#[cfg(test)] -mod age_tests { - use std::io::Write as _; - use std::path::Path; - - use age::secrecy::ExposeSecret; - - use crate::config::SecretResolver; - use zeph_vault::{AgeVaultError, AgeVaultProvider}; - - fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec { - let recipient = identity.to_public(); - let encryptor = - age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) - .expect("encryptor creation"); - let mut encrypted = vec![]; - let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output"); - writer - .write_all(json.to_string().as_bytes()) - .expect("write plaintext"); - writer.finish().expect("finish encryption"); - encrypted - } - - fn write_temp_files( - identity: &age::x25519::Identity, - ciphertext: &[u8], - ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { - let dir = tempfile::tempdir().expect("tempdir"); - let key_path = dir.path().join("key.txt"); - let vault_path = dir.path().join("secrets.age"); - std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key"); - std::fs::write(&vault_path, ciphertext).expect("write vault"); - (dir, key_path, vault_path) - } - - #[tokio::test] - async fn age_encrypt_decrypt_resolve_secrets_roundtrip() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({ - "ZEPH_CLAUDE_API_KEY": "sk-ant-test-123", - "ZEPH_TELEGRAM_TOKEN": "tg-token-456" - }); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); - let mut config = - crate::config::Config::load(Path::new("/nonexistent/config.toml")).unwrap(); - config.resolve_secrets(&vault).await.unwrap(); - - assert_eq!( - config.secrets.claude_api_key.as_ref().unwrap().expose(), - "sk-ant-test-123" - ); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("tg-token-456")); - } - - // Suppress unused import warning when age is not in scope (satisfies clippy) - #[allow(dead_code)] - fn _use_age_vault_error(_: AgeVaultError) {} -} diff --git a/crates/zeph-core/tests/vault_integration.rs b/crates/zeph-core/tests/vault_integration.rs new file mode 100644 index 00000000..b66bfa2c --- /dev/null +++ b/crates/zeph-core/tests/vault_integration.rs @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Integration tests for vault + config resolution. + +use std::io::Write as _; +use std::path::Path; + +use age::secrecy::ExposeSecret; + +use zeph_core::config::SecretResolver; +use zeph_vault::{AgeVaultError, AgeVaultProvider}; + +fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec { + let recipient = identity.to_public(); + let encryptor = + age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) + .expect("encryptor creation"); + let mut encrypted = vec![]; + let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output"); + writer + .write_all(json.to_string().as_bytes()) + .expect("write plaintext"); + writer.finish().expect("finish encryption"); + encrypted +} + +fn write_temp_files( + identity: &age::x25519::Identity, + ciphertext: &[u8], +) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let key_path = dir.path().join("key.txt"); + let vault_path = dir.path().join("secrets.age"); + std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key"); + std::fs::write(&vault_path, ciphertext).expect("write vault"); + (dir, key_path, vault_path) +} + +#[tokio::test] +async fn age_encrypt_decrypt_resolve_secrets_roundtrip() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({ + "ZEPH_CLAUDE_API_KEY": "sk-ant-test-123", + "ZEPH_TELEGRAM_TOKEN": "tg-token-456" + }); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); + let mut config = + zeph_core::config::Config::load(Path::new("/nonexistent/config.toml")).unwrap(); + config.resolve_secrets(&vault).await.unwrap(); + + assert_eq!( + config.secrets.claude_api_key.as_ref().unwrap().expose(), + "sk-ant-test-123" + ); + let tg = config.telegram.unwrap(); + assert_eq!(tg.token.as_deref(), Some("tg-token-456")); +} + +// Suppress unused import warning when age is not in scope (satisfies clippy) +#[allow(dead_code)] +fn _use_age_vault_error(_: AgeVaultError) {} From 801c4bc8b119656cab40b898ff6ba93d02e69ecf Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 00:45:15 +0100 Subject: [PATCH 05/10] refactor(phase-1c): Remove vault.rs file, add re-export module to lib.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed crates/zeph-core/src/vault.rs (was just re-export shim) - Added explicit re-export module in lib.rs to preserve crate::vault:: paths - No wildcard imports — explicit re-exports only - All 6077 tests pass --- crates/zeph-core/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index 41889633..0016259a 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -18,7 +18,6 @@ pub mod metrics; pub mod pipeline; pub mod project; pub mod redact; -pub mod vault; #[cfg(feature = "experiments")] pub mod experiments; @@ -59,3 +58,11 @@ pub use sanitizer::{ }; pub use skill_loader::SkillLoaderExecutor; pub use zeph_tools::executor::DiffData; + +// Re-export vault module to preserve internal import paths (e.g., `crate::vault::VaultProvider`). +pub mod vault { + pub use zeph_vault::{ + AgeVaultError, AgeVaultProvider, ArcAgeVaultProvider, EnvVaultProvider, + MockVaultProvider, Secret, VaultError, VaultProvider, default_vault_dir, + }; +} From dd5714029c49235fadd830684c156c537b1108e9 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 00:57:58 +0100 Subject: [PATCH 06/10] refactor(phase-1c): Clean up config module and remove vault.rs file - Moved SecretResolver trait from src/config/ folder to config.rs file - Removed re-exports from config.rs (only SecurityResolver + imports) - Deleted src/config/ folder (now config.rs file only) - Removed vault.rs, added vault re-export module to lib.rs - Integration test moved to tests/vault_integration.rs - Restored crates/zeph-core/config/default.toml (needed for bootstrap tests) - All 5917 tests pass Phase 1c now has clean architecture: - Layer 0: zeph-common (Secret, VaultError) - Layer 1: zeph-vault (VaultProvider + implementations) - Layer 1: zeph-config (Config struct + loaders) - Layer 2: zeph-core (SecretResolver trait, re-exports for API coherence) --- .../src/{config/mod.rs => config.rs} | 76 +- crates/zeph-core/src/config/tests.rs | 3243 ----------------- crates/zeph-core/src/lib.rs | 7 +- crates/zeph-core/src/vault.rs | 5 - 4 files changed, 53 insertions(+), 3278 deletions(-) rename crates/zeph-core/src/{config/mod.rs => config.rs} (70%) delete mode 100644 crates/zeph-core/src/config/tests.rs delete mode 100644 crates/zeph-core/src/vault.rs diff --git a/crates/zeph-core/src/config/mod.rs b/crates/zeph-core/src/config.rs similarity index 70% rename from crates/zeph-core/src/config/mod.rs rename to crates/zeph-core/src/config.rs index ccb47d86..b61c6a46 100644 --- a/crates/zeph-core/src/config/mod.rs +++ b/crates/zeph-core/src/config.rs @@ -1,35 +1,27 @@ // SPDX-FileCopyrightText: 2026 Andrei G // SPDX-License-Identifier: MIT OR Apache-2.0 -// Config is defined in zeph-config. Inherent impls (load, validate, env overrides, -// normalize_legacy_runtime_defaults) live there. Only trait impls (SecretResolver) -// can be added here due to Rust orphan rules. -pub mod migrate { - pub use zeph_config::migrate::*; -} - -#[cfg(test)] -mod tests; +//! Extension trait for resolving vault secrets into a Config. +//! +//! This trait is defined in zeph-core (not in zeph-config) due to Rust's orphan rule: +//! implementing a foreign trait on a foreign type requires the trait to be defined locally. -pub use zeph_config::{Config, ConfigError, ResolvedSecrets}; -pub use zeph_tools::AutonomyLevel; - -// Re-export all previously available types so downstream users see no change. +// Re-export Config types from zeph-config for internal use. pub use zeph_config::{ AcpConfig, AcpLspConfig, AcpTransport, AgentConfig, CandleConfig, CascadeClassifierMode, CascadeConfig, CloudLlmConfig, CompatibleConfig, CompressionConfig, CompressionStrategy, - CostConfig, DaemonConfig, DebugConfig, DetectorMode, DiscordConfig, DocumentConfig, DumpFormat, - ExperimentConfig, ExperimentSchedule, FocusConfig, GatewayConfig, GeminiConfig, - GenerationParams, GraphConfig, HookDef, HookMatcher, HookType, IndexConfig, LearningConfig, - LlmConfig, LogRotation, LoggingConfig, MAX_TOKENS_CAP, McpConfig, McpOAuthConfig, - McpServerConfig, MemoryConfig, MemoryScope, NoteLinkingConfig, OAuthTokenStorage, - ObservabilityConfig, OllamaConfig, OpenAiConfig, OrchestrationConfig, OrchestratorConfig, - OrchestratorProviderConfig, PermissionMode, ProviderKind, PruningStrategy, RateLimitConfig, - RouterConfig, RouterStrategyConfig, RoutingConfig, RoutingStrategy, ScheduledTaskConfig, - ScheduledTaskKind, SchedulerConfig, SecurityConfig, SemanticConfig, SessionsConfig, - SidequestConfig, SkillFilter, SkillPromptMode, SkillsConfig, SlackConfig, SttConfig, - SubAgentConfig, SubAgentLifecycleHooks, SubagentHooks, TelegramConfig, TimeoutConfig, - ToolPolicy, TraceConfig, TrustConfig, TuiConfig, VaultConfig, VectorBackend, + Config, ConfigError, CostConfig, DaemonConfig, DebugConfig, DetectorMode, DiscordConfig, + DocumentConfig, DumpFormat, ExperimentConfig, ExperimentSchedule, FocusConfig, GatewayConfig, + GeminiConfig, GenerationParams, GraphConfig, HookDef, HookMatcher, HookType, IndexConfig, + LearningConfig, LlmConfig, LogRotation, LoggingConfig, MAX_TOKENS_CAP, McpConfig, + McpOAuthConfig, McpServerConfig, MemoryConfig, MemoryScope, NoteLinkingConfig, + OAuthTokenStorage, ObservabilityConfig, OllamaConfig, OpenAiConfig, OrchestrationConfig, + OrchestratorConfig, OrchestratorProviderConfig, PermissionMode, ProviderKind, PruningStrategy, + RateLimitConfig, ResolvedSecrets, RouterConfig, RouterStrategyConfig, RoutingConfig, + RoutingStrategy, ScheduledTaskConfig, ScheduledTaskKind, SchedulerConfig, SecurityConfig, + SemanticConfig, SessionsConfig, SidequestConfig, SkillFilter, SkillPromptMode, SkillsConfig, + SlackConfig, SttConfig, SubAgentConfig, SubAgentLifecycleHooks, SubagentHooks, TelegramConfig, + TimeoutConfig, ToolPolicy, TraceConfig, TrustConfig, TuiConfig, VaultConfig, VectorBackend, }; #[cfg(feature = "lsp-context")] @@ -54,7 +46,11 @@ pub use zeph_config::{ pub use zeph_config::providers::{default_stt_language, default_stt_model, default_stt_provider}; -use crate::vault::VaultProvider; +pub mod migrate { + pub use zeph_config::migrate::*; +} + +use crate::vault::{Secret, VaultProvider}; /// Extension trait for resolving vault secrets into a [`Config`]. /// @@ -74,8 +70,6 @@ pub trait SecretResolver { impl SecretResolver for Config { async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> { - use crate::vault::Secret; - if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? { self.secrets.claude_api_key = Some(Secret::new(val)); } @@ -154,3 +148,29 @@ impl SecretResolver for Config { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[cfg(any(test, feature = "mock"))] + async fn resolve_secrets_with_mock_vault() { + use crate::vault::MockVaultProvider; + + let vault = MockVaultProvider::new() + .with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123") + .with_secret("ZEPH_TELEGRAM_TOKEN", "tg-token-456"); + + let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap(); + config.resolve_secrets(&vault).await.unwrap(); + + assert_eq!( + config.secrets.claude_api_key.as_ref().unwrap().expose(), + "sk-test-123" + ); + if let Some(tg) = config.telegram { + assert_eq!(tg.token.as_deref(), Some("tg-token-456")); + } + } +} diff --git a/crates/zeph-core/src/config/tests.rs b/crates/zeph-core/src/config/tests.rs deleted file mode 100644 index 9a2a8ad9..00000000 --- a/crates/zeph-core/src/config/tests.rs +++ /dev/null @@ -1,3243 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Andrei G -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// std::env::set_var / remove_var are unsafe in Rust 2024 edition; all callers are #[serial]. -#![allow(unsafe_code)] -#![allow( - clippy::default_trait_access, - clippy::doc_markdown, - clippy::field_reassign_with_default, - clippy::unnecessary_get_then_check -)] -use std::collections::HashMap; -use std::io::Write; - -use serial_test::serial; - -/// Test helper: verify a Secret in a `HashMap` matches the expected plaintext. -/// Separated to avoid `CodeQL` cleartext-logging false positives on `.get().expose()`. -fn assert_custom_secret(custom: &HashMap, key: &str, expected: &str) { - let actual = custom - .get(key) - .unwrap_or_else(|| panic!("missing key: {key}")); - assert_eq!(actual.expose(), expected, "secret mismatch for key: {key}"); -} - -use super::*; - -const ENV_KEYS: [&str; 53] = [ - "ZEPH_LLM_PROVIDER", - "ZEPH_LLM_BASE_URL", - "ZEPH_LLM_MODEL", - "ZEPH_LLM_EMBEDDING_MODEL", - "ZEPH_CLAUDE_API_KEY", - "ZEPH_OPENAI_API_KEY", - "ZEPH_SQLITE_PATH", - "ZEPH_QDRANT_URL", - "ZEPH_MEMORY_SUMMARIZATION_THRESHOLD", - "ZEPH_MEMORY_CONTEXT_BUDGET_TOKENS", - "ZEPH_MEMORY_COMPACTION_THRESHOLD", - "ZEPH_MEMORY_SOFT_COMPACTION_THRESHOLD", - "ZEPH_MEMORY_COMPACTION_PRESERVE_TAIL", - "ZEPH_MEMORY_PRUNE_PROTECT_TOKENS", - "ZEPH_MEMORY_SEMANTIC_ENABLED", - "ZEPH_MEMORY_RECALL_LIMIT", - "ZEPH_SKILLS_MAX_ACTIVE", - "ZEPH_TELEGRAM_TOKEN", - "ZEPH_A2A_AUTH_TOKEN", - "ZEPH_A2A_ENABLED", - "ZEPH_A2A_HOST", - "ZEPH_A2A_PORT", - "ZEPH_A2A_PUBLIC_URL", - "ZEPH_A2A_RATE_LIMIT", - "ZEPH_A2A_REQUIRE_TLS", - "ZEPH_A2A_SSRF_PROTECTION", - "ZEPH_A2A_MAX_BODY_SIZE", - "ZEPH_SECURITY_REDACT_SECRETS", - "ZEPH_TIMEOUT_LLM", - "ZEPH_TIMEOUT_EMBEDDING", - "ZEPH_TIMEOUT_A2A", - "ZEPH_TOOLS_TIMEOUT", - "ZEPH_TOOLS_SHELL_ALLOWED_COMMANDS", - "ZEPH_TOOLS_SHELL_ALLOWED_PATHS", - "ZEPH_TOOLS_SHELL_ALLOW_NETWORK", - "ZEPH_TOOLS_SCRAPE_TIMEOUT", - "ZEPH_TOOLS_SCRAPE_MAX_BODY", - "ZEPH_TOOLS_AUDIT_ENABLED", - "ZEPH_TOOLS_AUDIT_DESTINATION", - "ZEPH_SKILLS_LEARNING_ENABLED", - "ZEPH_SKILLS_LEARNING_AUTO_ACTIVATE", - "ZEPH_TOOLS_SUMMARIZE_OUTPUT", - "ZEPH_MEMORY_AUTO_BUDGET", - "ZEPH_INDEX_ENABLED", - "ZEPH_INDEX_MAX_CHUNKS", - "ZEPH_INDEX_SCORE_THRESHOLD", - "ZEPH_INDEX_BUDGET_RATIO", - "ZEPH_INDEX_REPO_MAP_TOKENS", - "ZEPH_STT_PROVIDER", - "ZEPH_STT_MODEL", - "ZEPH_AUTO_UPDATE_CHECK", - "ZEPH_LOG_FILE", - "ZEPH_LOG_LEVEL", -]; - -fn clear_env() { - for key in ENV_KEYS { - unsafe { std::env::remove_var(key) }; - } -} - -#[test] -fn defaults_when_file_missing() { - let config = Config::default(); - assert_eq!(config.llm.provider, super::ProviderKind::Ollama); - assert_eq!(config.llm.base_url, "http://localhost:11434"); - assert_eq!(config.llm.model, "qwen3:8b"); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); - assert_eq!(config.agent.name, "Zeph"); - assert_eq!(config.memory.history_limit, 50); - assert_eq!(config.memory.qdrant_url, "http://localhost:6334"); - assert!(config.llm.cloud.is_none()); - assert!(config.llm.openai.is_none()); - assert!(config.telegram.is_none()); - assert!(config.tools.enabled); - assert_eq!(config.tools.shell.timeout, 30); - assert!(config.tools.shell.blocked_commands.is_empty()); - assert_eq!(config.skills.paths, vec![default_skills_dir()]); - assert_eq!(config.memory.sqlite_path, default_sqlite_path()); - assert_eq!(config.debug.output_dir, default_debug_dir()); - assert_eq!(config.logging.file, default_log_file_path()); -} - -#[test] -#[serial] -fn legacy_runtime_defaults_are_rewritten_but_custom_relative_paths_are_preserved() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("legacy-defaults.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "TestBot" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" -embedding_model = "qwen3-embedding" - -[skills] -paths = [".zeph/skills", "./extra-skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[debug] -output_dir = ".zeph/debug" - -[logging] -file = ".zeph/logs/zeph.log" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.skills.paths[0], default_skills_dir()); - assert_eq!(config.skills.paths[1], "./extra-skills"); - assert_eq!(config.memory.sqlite_path, default_sqlite_path()); - assert_eq!(config.debug.output_dir, default_debug_dir()); - assert_eq!(config.logging.file, default_log_file_path()); -} - -#[test] -#[serial] -fn parse_valid_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "TestBot" - -[llm] -provider = "ollama" -base_url = "http://custom:1234" -model = "llama3:8b" - -[skills] -paths = ["./s"] - -[memory] -sqlite_path = "./test.db" -history_limit = 10 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.agent.name, "TestBot"); - assert_eq!(config.llm.base_url, "http://custom:1234"); - assert_eq!(config.llm.model, "llama3:8b"); - assert_eq!(config.memory.history_limit, 10); -} - -#[test] -#[serial] -fn parse_toml_with_cloud() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cloud.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "claude" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.cloud] -model = "claude-sonnet-4-5-20250929" -max_tokens = 4096 - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.provider, super::ProviderKind::Claude); - let cloud = config.llm.cloud.unwrap(); - assert_eq!(cloud.model, "claude-sonnet-4-5-20250929"); - assert_eq!(cloud.max_tokens, 4096); -} - -#[test] -#[serial] -fn env_overrides() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.llm.model, "qwen3:8b"); - - unsafe { std::env::set_var("ZEPH_LLM_MODEL", "phi3:mini") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LLM_MODEL") }; - - assert_eq!(config.llm.model, "phi3:mini"); -} - -#[test] -#[serial] -fn telegram_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("tg.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[telegram] -token = "123:ABC" -allowed_users = ["alice", "bob"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("123:ABC")); - assert_eq!(tg.allowed_users, vec!["alice", "bob"]); -} - -#[tokio::test] -async fn resolve_secrets_populates_telegram_token() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_TELEGRAM_TOKEN", "vault-token"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("vault-token")); -} - -#[test] -#[serial] -fn config_with_tools_section() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("tools.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[tools] -enabled = true - -[tools.shell] -timeout = 60 -blocked_commands = ["custom-danger"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.tools.enabled); - assert_eq!(config.tools.shell.timeout, 60); - assert_eq!(config.tools.shell.blocked_commands, vec!["custom-danger"]); -} - -#[test] -#[serial] -fn config_without_tools_section() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_tools.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.tools.enabled); - assert_eq!(config.tools.shell.timeout, 30); - assert!(config.tools.shell.blocked_commands.is_empty()); -} - -#[test] -#[serial] -fn env_override_tools_timeout() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.tools.shell.timeout, 30); - - unsafe { std::env::set_var("ZEPH_TOOLS_TIMEOUT", "120") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_TIMEOUT") }; - - assert_eq!(config.tools.shell.timeout, 120); -} - -#[test] -#[serial] -fn env_override_tools_timeout_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.tools.shell.timeout, 30); - - unsafe { std::env::set_var("ZEPH_TOOLS_TIMEOUT", "not-a-number") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_TIMEOUT") }; - - assert_eq!(config.tools.shell.timeout, 30); -} - -#[test] -#[serial] -fn env_override_allowed_commands() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.shell.allowed_commands.is_empty()); - - unsafe { std::env::set_var("ZEPH_TOOLS_SHELL_ALLOWED_COMMANDS", "curl, wget , ") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SHELL_ALLOWED_COMMANDS") }; - - assert_eq!(config.tools.shell.allowed_commands, vec!["curl", "wget"]); -} - -#[test] -fn config_default_embedding_model() { - let config = Config::default(); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); -} - -#[test] -#[serial] -fn config_parse_embedding_model() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("embed.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" -embedding_model = "nomic-embed-text" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.embedding_model, "nomic-embed-text"); -} - -#[test] -#[serial] -fn config_env_override_embedding_model() { - let mut config = Config::default(); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); - - unsafe { std::env::set_var("ZEPH_LLM_EMBEDDING_MODEL", "mxbai-embed-large") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LLM_EMBEDDING_MODEL") }; - - assert_eq!(config.llm.embedding_model, "mxbai-embed-large"); -} - -#[test] -#[serial] -fn config_missing_embedding_model_uses_default() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_embed.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); -} - -#[test] -fn config_default_qdrant_url() { - let config = Config::default(); - assert_eq!(config.memory.qdrant_url, "http://localhost:6334"); -} - -#[test] -#[serial] -fn config_parse_qdrant_url() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("qdrant.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -qdrant_url = "http://qdrant:6334" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.memory.qdrant_url, "http://qdrant:6334"); -} - -#[test] -#[serial] -fn config_env_override_qdrant_url() { - let mut config = Config::default(); - assert_eq!(config.memory.qdrant_url, "http://localhost:6334"); - - unsafe { std::env::set_var("ZEPH_QDRANT_URL", "http://remote:6334") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_QDRANT_URL") }; - - assert_eq!(config.memory.qdrant_url, "http://remote:6334"); -} - -#[test] -fn config_default_summarization_threshold() { - let config = Config::default(); - assert_eq!(config.memory.summarization_threshold, 50); -} - -#[test] -#[serial] -fn config_parse_summarization_threshold() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sum.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -summarization_threshold = 200 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.memory.summarization_threshold, 200); -} - -#[test] -#[serial] -fn config_env_override_summarization_threshold() { - let mut config = Config::default(); - assert_eq!(config.memory.summarization_threshold, 50); - - unsafe { std::env::set_var("ZEPH_MEMORY_SUMMARIZATION_THRESHOLD", "150") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_SUMMARIZATION_THRESHOLD") }; - - assert_eq!(config.memory.summarization_threshold, 150); -} - -#[test] -fn config_default_context_budget_tokens() { - let config = Config::default(); - assert_eq!(config.memory.context_budget_tokens, 0); -} - -#[test] -fn config_parse_context_budget_tokens() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("budget.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -context_budget_tokens = 4096 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.memory.context_budget_tokens, 4096); -} - -#[test] -#[serial] -fn config_env_override_context_budget_tokens() { - let mut config = Config::default(); - assert_eq!(config.memory.context_budget_tokens, 0); - - unsafe { std::env::set_var("ZEPH_MEMORY_CONTEXT_BUDGET_TOKENS", "8192") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_CONTEXT_BUDGET_TOKENS") }; - - assert_eq!(config.memory.context_budget_tokens, 8192); -} - -#[test] -fn learning_config_defaults() { - let config = Config::default(); - let lc = &config.skills.learning; - assert!(!lc.enabled); - assert!(!lc.auto_activate); - assert_eq!(lc.min_failures, 3); - assert!((lc.improve_threshold - 0.7).abs() < f64::EPSILON); - assert!((lc.rollback_threshold - 0.5).abs() < f64::EPSILON); - assert_eq!(lc.min_evaluations, 5); - assert_eq!(lc.max_versions, 10); - assert_eq!(lc.cooldown_minutes, 60); -} - -#[test] -fn parse_toml_with_learning_section() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("learn.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[skills.learning] -enabled = true -auto_activate = true -min_failures = 5 -improve_threshold = 0.6 -rollback_threshold = 0.4 -min_evaluations = 10 -max_versions = 20 -cooldown_minutes = 120 - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let lc = &config.skills.learning; - assert!(lc.enabled); - assert!(lc.auto_activate); - assert_eq!(lc.min_failures, 5); - assert!((lc.improve_threshold - 0.6).abs() < f64::EPSILON); - assert!((lc.rollback_threshold - 0.4).abs() < f64::EPSILON); - assert_eq!(lc.min_evaluations, 10); - assert_eq!(lc.max_versions, 20); - assert_eq!(lc.cooldown_minutes, 120); -} - -#[test] -#[serial] -fn parse_toml_without_learning_uses_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_learn.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(!config.skills.learning.enabled); - assert_eq!(config.skills.learning.min_failures, 3); -} - -#[test] -#[serial] -fn env_override_learning_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(!config.skills.learning.enabled); - - unsafe { std::env::set_var("ZEPH_SKILLS_LEARNING_ENABLED", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_LEARNING_ENABLED") }; - - assert!(config.skills.learning.enabled); -} - -#[test] -#[serial] -fn env_override_learning_auto_activate() { - clear_env(); - let mut config = Config::default(); - assert!(!config.skills.learning.auto_activate); - - unsafe { std::env::set_var("ZEPH_SKILLS_LEARNING_AUTO_ACTIVATE", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_LEARNING_AUTO_ACTIVATE") }; - - assert!(config.skills.learning.auto_activate); -} - -#[test] -#[serial] -fn env_override_learning_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - assert!(!config.skills.learning.enabled); - - unsafe { std::env::set_var("ZEPH_SKILLS_LEARNING_ENABLED", "not-a-bool") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_LEARNING_ENABLED") }; - - assert!(!config.skills.learning.enabled); -} - -#[tokio::test] -async fn resolve_secrets_populates_claude_api_key() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_eq!( - config.secrets.claude_api_key.as_ref().unwrap().expose(), - "sk-test-123" - ); -} - -#[tokio::test] -async fn resolve_secrets_populates_a2a_auth_token() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_A2A_AUTH_TOKEN", "a2a-secret"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_eq!(config.a2a.auth_token.as_deref(), Some("a2a-secret")); -} - -#[tokio::test] -async fn resolve_secrets_empty_vault_leaves_defaults() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new(); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!(config.secrets.claude_api_key.is_none()); - assert!(config.secrets.openai_api_key.is_none()); - assert!(config.telegram.is_none()); - assert!(config.a2a.auth_token.is_none()); -} - -#[tokio::test] -async fn resolve_secrets_overrides_toml_values() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_TELEGRAM_TOKEN", "vault-token"); - let mut config = Config::default(); - config.telegram = Some(TelegramConfig { - token: Some("toml-token".into()), - allowed_users: Vec::new(), - }); - config.resolve_secrets(&vault).await.unwrap(); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("vault-token")); -} - -#[test] -fn telegram_debug_redacts_token() { - let tg = TelegramConfig { - token: Some("secret-token".into()), - allowed_users: vec!["alice".into()], - }; - let debug = format!("{tg:?}"); - assert!(!debug.contains("secret-token")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn a2a_debug_redacts_auth_token() { - let a2a = A2aServerConfig { - auth_token: Some("secret-auth".into()), - ..A2aServerConfig::default() - }; - let debug = format!("{a2a:?}"); - assert!(!debug.contains("secret-auth")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn acp_debug_redacts_auth_token() { - let cfg = AcpConfig { - auth_token: Some("secret".to_string()), - ..AcpConfig::default() - }; - let debug = format!("{cfg:?}"); - assert!(!debug.contains("secret")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn vault_config_default_backend() { - let config = Config::default(); - assert_eq!(config.vault.backend, "env"); -} - -#[test] -fn mcp_config_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp-defaults.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Test" -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "m" -[skills] -paths = [".zeph/skills"] -[memory] -sqlite_path = ":memory:" -history_limit = 50 -qdrant_url = "http://localhost:6334" -[mcp] -"# - ) - .unwrap(); - let config = Config::load(&path).unwrap(); - assert!(config.mcp.servers.is_empty()); - assert!(config.mcp.allowed_commands.is_empty()); - assert_eq!(config.mcp.max_dynamic_servers, 10); -} - -#[test] -#[serial] -fn parse_toml_with_mcp() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[mcp] -allowed_commands = ["npx"] -max_dynamic_servers = 5 - -[[mcp.servers]] -id = "github" -command = "npx" -args = ["-y", "mcp-github"] -timeout = 60 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.mcp.allowed_commands, vec!["npx"]); - assert_eq!(config.mcp.max_dynamic_servers, 5); - assert_eq!(config.mcp.servers.len(), 1); - assert_eq!(config.mcp.servers[0].id, "github"); - assert_eq!(config.mcp.servers[0].command.as_deref(), Some("npx")); - assert_eq!(config.mcp.servers[0].timeout, 60); -} - -#[test] -#[serial] -fn parse_toml_mcp_http_server() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp_http.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[[mcp.servers]] -id = "remote" -url = "http://remote-mcp:8080" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.mcp.servers.len(), 1); - assert_eq!(config.mcp.servers[0].id, "remote"); - assert_eq!( - config.mcp.servers[0].url.as_deref(), - Some("http://remote-mcp:8080") - ); - assert!(config.mcp.servers[0].command.is_none()); - assert_eq!(config.mcp.servers[0].timeout, 30); -} - -#[test] -fn a2a_config_defaults() { - let config = Config::default(); - assert!(!config.a2a.enabled); - assert_eq!(config.a2a.host, "0.0.0.0"); - assert_eq!(config.a2a.port, 8080); - assert!(config.a2a.public_url.is_empty()); - assert!(config.a2a.auth_token.is_none()); - assert_eq!(config.a2a.rate_limit, 60); - assert!(config.a2a.require_tls); - assert!(config.a2a.ssrf_protection); - assert_eq!(config.a2a.max_body_size, 1_048_576); -} - -#[test] -#[serial] -fn parse_toml_with_a2a() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("a2a.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[a2a] -enabled = true -host = "127.0.0.1" -port = 9090 -public_url = "https://agent.example.com" -auth_token = "secret" -rate_limit = 120 -require_tls = false -ssrf_protection = false -max_body_size = 2097152 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.a2a.enabled); - assert_eq!(config.a2a.host, "127.0.0.1"); - assert_eq!(config.a2a.port, 9090); - assert_eq!(config.a2a.public_url, "https://agent.example.com"); - assert_eq!(config.a2a.auth_token.as_deref(), Some("secret")); - assert_eq!(config.a2a.rate_limit, 120); - assert!(!config.a2a.require_tls); - assert!(!config.a2a.ssrf_protection); - assert_eq!(config.a2a.max_body_size, 2_097_152); -} - -#[test] -fn security_config_defaults() { - let config = Config::default(); - assert!(config.security.redact_secrets); -} - -#[test] -#[serial] -fn parse_toml_with_security() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sec.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[security] -redact_secrets = false -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(!config.security.redact_secrets); -} - -#[test] -fn timeout_config_defaults() { - let config = Config::default(); - assert_eq!(config.timeouts.llm_seconds, 120); - assert_eq!(config.timeouts.embedding_seconds, 30); - assert_eq!(config.timeouts.a2a_seconds, 30); -} - -#[test] -#[serial] -fn parse_toml_with_timeouts() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("timeouts.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[timeouts] -llm_seconds = 60 -embedding_seconds = 15 -a2a_seconds = 10 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.timeouts.llm_seconds, 60); - assert_eq!(config.timeouts.embedding_seconds, 15); - assert_eq!(config.timeouts.a2a_seconds, 10); -} - -#[test] -#[serial] -fn parse_toml_with_vault() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("vault.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[vault] -backend = "age" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.vault.backend, "age"); -} - -#[test] -#[serial] -fn env_override_a2a_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(!config.a2a.enabled); - - unsafe { std::env::set_var("ZEPH_A2A_ENABLED", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_ENABLED") }; - - assert!(config.a2a.enabled); -} - -#[test] -#[serial] -fn env_override_a2a_host_port() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_A2A_HOST", "127.0.0.1") }; - unsafe { std::env::set_var("ZEPH_A2A_PORT", "3000") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_HOST") }; - unsafe { std::env::remove_var("ZEPH_A2A_PORT") }; - - assert_eq!(config.a2a.host, "127.0.0.1"); - assert_eq!(config.a2a.port, 3000); -} - -#[test] -#[serial] -fn env_override_a2a_rate_limit() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_A2A_RATE_LIMIT", "200") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_RATE_LIMIT") }; - - assert_eq!(config.a2a.rate_limit, 200); -} - -#[test] -#[serial] -fn env_override_security_redact() { - clear_env(); - let mut config = Config::default(); - assert!(config.security.redact_secrets); - - unsafe { std::env::set_var("ZEPH_SECURITY_REDACT_SECRETS", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SECURITY_REDACT_SECRETS") }; - - assert!(!config.security.redact_secrets); -} - -#[test] -#[serial] -fn env_override_timeout_llm() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TIMEOUT_LLM", "300") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TIMEOUT_LLM") }; - - assert_eq!(config.timeouts.llm_seconds, 300); -} - -#[test] -#[serial] -fn env_override_timeout_embedding() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TIMEOUT_EMBEDDING", "45") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TIMEOUT_EMBEDDING") }; - - assert_eq!(config.timeouts.embedding_seconds, 45); -} - -#[test] -#[serial] -fn env_override_timeout_a2a() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TIMEOUT_A2A", "90") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TIMEOUT_A2A") }; - - assert_eq!(config.timeouts.a2a_seconds, 90); -} - -#[test] -#[serial] -fn env_override_a2a_require_tls() { - clear_env(); - let mut config = Config::default(); - assert!(config.a2a.require_tls); - - unsafe { std::env::set_var("ZEPH_A2A_REQUIRE_TLS", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_REQUIRE_TLS") }; - - assert!(!config.a2a.require_tls); -} - -#[test] -#[serial] -fn env_override_a2a_ssrf_protection() { - clear_env(); - let mut config = Config::default(); - assert!(config.a2a.ssrf_protection); - - unsafe { std::env::set_var("ZEPH_A2A_SSRF_PROTECTION", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_SSRF_PROTECTION") }; - - assert!(!config.a2a.ssrf_protection); -} - -#[test] -#[serial] -fn env_override_a2a_max_body_size() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_A2A_MAX_BODY_SIZE", "524288") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_MAX_BODY_SIZE") }; - - assert_eq!(config.a2a.max_body_size, 524_288); -} - -#[test] -#[serial] -fn env_override_scrape_timeout() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TOOLS_SCRAPE_TIMEOUT", "60") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SCRAPE_TIMEOUT") }; - - assert_eq!(config.tools.scrape.timeout, 60); -} - -#[test] -#[serial] -fn env_override_scrape_max_body() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TOOLS_SCRAPE_MAX_BODY", "2097152") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SCRAPE_MAX_BODY") }; - - assert_eq!(config.tools.scrape.max_body_bytes, 2_097_152); -} - -#[test] -#[serial] -fn env_override_shell_allowed_paths() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.shell.allowed_paths.is_empty()); - - unsafe { std::env::set_var("ZEPH_TOOLS_SHELL_ALLOWED_PATHS", "/tmp, /home") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SHELL_ALLOWED_PATHS") }; - - assert_eq!(config.tools.shell.allowed_paths, vec!["/tmp", "/home"]); -} - -#[test] -#[serial] -fn env_override_shell_allow_network() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.shell.allow_network); - - unsafe { std::env::set_var("ZEPH_TOOLS_SHELL_ALLOW_NETWORK", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SHELL_ALLOW_NETWORK") }; - - assert!(!config.tools.shell.allow_network); -} - -#[test] -#[serial] -fn env_override_audit_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(!config.tools.audit.enabled); - - unsafe { std::env::set_var("ZEPH_TOOLS_AUDIT_ENABLED", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_AUDIT_ENABLED") }; - - assert!(config.tools.audit.enabled); -} - -#[test] -#[serial] -fn env_override_audit_destination() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TOOLS_AUDIT_DESTINATION", "/var/log/audit.log") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_AUDIT_DESTINATION") }; - - assert_eq!(config.tools.audit.destination, "/var/log/audit.log"); -} - -#[test] -#[serial] -fn env_override_semantic_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(config.memory.semantic.enabled); - - unsafe { std::env::set_var("ZEPH_MEMORY_SEMANTIC_ENABLED", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_SEMANTIC_ENABLED") }; - - assert!(!config.memory.semantic.enabled); -} - -#[test] -#[serial] -fn env_override_recall_limit() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.memory.semantic.recall_limit, 5); - - unsafe { std::env::set_var("ZEPH_MEMORY_RECALL_LIMIT", "20") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_RECALL_LIMIT") }; - - assert_eq!(config.memory.semantic.recall_limit, 20); -} - -#[test] -#[serial] -fn env_override_skills_max_active() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.skills.max_active_skills, 5); - - unsafe { std::env::set_var("ZEPH_SKILLS_MAX_ACTIVE", "10") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_MAX_ACTIVE") }; - - assert_eq!(config.skills.max_active_skills, 10); -} - -#[test] -#[serial] -fn env_override_a2a_public_url() { - clear_env(); - let mut config = Config::default(); - assert!(config.a2a.public_url.is_empty()); - - unsafe { std::env::set_var("ZEPH_A2A_PUBLIC_URL", "https://my-agent.dev") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_PUBLIC_URL") }; - - assert_eq!(config.a2a.public_url, "https://my-agent.dev"); -} - -#[test] -fn mcp_server_config_debug_redacts_env() { - let mcp = McpServerConfig { - id: "test".into(), - command: Some("npx".into()), - args: vec![], - env: HashMap::from([("SECRET".into(), "super-secret".into())]), - url: None, - headers: HashMap::new(), - oauth: None, - timeout: 30, - policy: Default::default(), - }; - let debug = format!("{mcp:?}"); - assert!(!debug.contains("super-secret")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -#[serial] -fn mcp_server_config_default_timeout() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp_default_timeout.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[[mcp.servers]] -id = "test" -command = "cmd" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.mcp.servers[0].timeout, 30); -} - -#[test] -fn config_load_nonexistent_file_uses_defaults() { - let path = std::path::Path::new("/nonexistent/config.toml"); - let config = Config::load(path).unwrap(); - assert_eq!(config.agent.name, "Zeph"); - assert_eq!(config.llm.provider, super::ProviderKind::Ollama); -} - -#[test] -fn generation_params_defaults() { - let params = GenerationParams::default(); - assert!((params.temperature - 0.7).abs() < f64::EPSILON); - assert!(params.top_p.is_none()); - assert!(params.top_k.is_none()); - assert_eq!(params.max_tokens, 2048); - assert_eq!(params.seed, 42); - assert!((params.repeat_penalty - 1.1).abs() < f32::EPSILON); - assert_eq!(params.repeat_last_n, 64); -} - -#[test] -fn generation_params_capped_max_tokens() { - let mut params = GenerationParams::default(); - params.max_tokens = 100_000; - assert_eq!(params.capped_max_tokens(), 32_768); -} - -#[test] -fn generation_params_capped_below_cap() { - let params = GenerationParams::default(); - assert_eq!(params.capped_max_tokens(), 2048); -} - -#[test] -fn semantic_config_defaults() { - let config = SemanticConfig::default(); - assert!(config.enabled); - assert_eq!(config.recall_limit, 5); -} - -#[test] -fn resolved_secrets_default() { - let secrets = ResolvedSecrets::default(); - assert!(secrets.claude_api_key.is_none()); - assert!(secrets.openai_api_key.is_none()); -} - -#[test] -#[serial] -fn parse_toml_with_all_sections() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("full.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "FullBot" - -[llm] -provider = "claude" -base_url = "http://localhost:11434" -model = "qwen3:8b" -embedding_model = "nomic" - -[llm.cloud] -model = "claude-sonnet-4-5-20250929" -max_tokens = 8192 - -[skills] -paths = [".zeph/skills", "./extra-skills"] -max_active_skills = 3 - -[skills.learning] -enabled = true -min_failures = 5 - -[memory] -sqlite_path = "./data/test.db" -history_limit = 100 -qdrant_url = "http://qdrant:6334" -summarization_threshold = 50 -context_budget_tokens = 4096 - -[memory.semantic] -enabled = true -recall_limit = 10 - -[telegram] -token = "123:TOKEN" -allowed_users = ["admin"] - -[tools] -enabled = true - -[tools.shell] -timeout = 90 -blocked_commands = ["rm"] -allowed_commands = ["curl"] -allowed_paths = ["/tmp"] -allow_network = false - -[tools.scrape] -timeout = 30 -max_body_bytes = 2097152 - -[tools.audit] -enabled = true -destination = "/var/log/zeph.log" - -[a2a] -enabled = true -host = "127.0.0.1" -port = 9090 -rate_limit = 100 - -[mcp] -max_dynamic_servers = 3 - -[vault] -backend = "age" - -[security] -redact_secrets = false - -[timeouts] -llm_seconds = 60 -embedding_seconds = 10 -a2a_seconds = 15 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.agent.name, "FullBot"); - assert_eq!(config.llm.provider, super::ProviderKind::Claude); - assert_eq!(config.llm.embedding_model, "nomic"); - assert!(config.llm.cloud.is_some()); - assert_eq!(config.skills.paths.len(), 2); - assert_eq!(config.skills.max_active_skills, 3); - assert!(config.skills.learning.enabled); - assert_eq!(config.memory.history_limit, 100); - assert!(config.memory.semantic.enabled); - assert_eq!(config.memory.semantic.recall_limit, 10); - assert!(config.telegram.is_some()); - assert!(!config.tools.shell.allow_network); - assert!(config.tools.audit.enabled); - assert!(config.a2a.enabled); - assert_eq!(config.mcp.max_dynamic_servers, 3); - assert_eq!(config.vault.backend, "age"); - assert!(!config.security.redact_secrets); - assert_eq!(config.timeouts.llm_seconds, 60); -} - -#[test] -#[serial] -fn parse_toml_with_openai() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-4o" -max_tokens = 4096 -embedding_model = "text-embedding-3-small" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.provider, super::ProviderKind::OpenAi); - let openai = config.llm.openai.unwrap(); - assert_eq!(openai.base_url, "https://api.openai.com/v1"); - assert_eq!(openai.model, "gpt-4o"); - assert_eq!(openai.max_tokens, 4096); - assert_eq!( - openai.embedding_model.as_deref(), - Some("text-embedding-3-small") - ); -} - -#[test] -#[serial] -fn parse_toml_openai_without_embedding_model() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai_no_embed.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-4o" -max_tokens = 4096 - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let openai = config.llm.openai.unwrap(); - assert!(openai.embedding_model.is_none()); -} - -#[tokio::test] -async fn resolve_secrets_populates_openai_api_key() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-123"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_eq!( - config.secrets.openai_api_key.as_ref().unwrap().expose(), - "sk-openai-123" - ); -} - -#[test] -#[serial] -fn parse_toml_openai_with_reasoning_effort() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai_reasoning.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-5.2" -max_tokens = 4096 -reasoning_effort = "high" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let openai = config.llm.openai.unwrap(); - assert_eq!(openai.reasoning_effort.as_deref(), Some("high")); -} - -#[test] -#[serial] -fn parse_toml_openai_without_reasoning_effort() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai_no_reasoning.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-5.2" -max_tokens = 4096 - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let openai = config.llm.openai.unwrap(); - assert!(openai.reasoning_effort.is_none()); -} - -#[test] -fn compaction_config_defaults() { - let config = Config::default(); - assert!((config.memory.soft_compaction_threshold - 0.60).abs() < f32::EPSILON); - assert!((config.memory.hard_compaction_threshold - 0.90).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 6); -} - -#[test] -#[serial] -fn compaction_config_parsing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("compact.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -soft_compaction_threshold = 0.75 -hard_compaction_threshold = 0.95 -compaction_preserve_tail = 6 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!((config.memory.soft_compaction_threshold - 0.75).abs() < f32::EPSILON); - assert!((config.memory.hard_compaction_threshold - 0.95).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 6); -} - -#[test] -#[serial] -fn compaction_env_overrides() { - clear_env(); - let mut config = Config::default(); - assert!((config.memory.soft_compaction_threshold - 0.60).abs() < f32::EPSILON); - assert!((config.memory.hard_compaction_threshold - 0.90).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 6); - - // ZEPH_MEMORY_COMPACTION_THRESHOLD maps to hard_compaction_threshold (backward compat). - unsafe { std::env::set_var("ZEPH_MEMORY_COMPACTION_THRESHOLD", "0.85") }; - unsafe { std::env::set_var("ZEPH_MEMORY_SOFT_COMPACTION_THRESHOLD", "0.60") }; - unsafe { std::env::set_var("ZEPH_MEMORY_COMPACTION_PRESERVE_TAIL", "8") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_COMPACTION_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_MEMORY_SOFT_COMPACTION_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_MEMORY_COMPACTION_PRESERVE_TAIL") }; - - assert!((config.memory.hard_compaction_threshold - 0.85).abs() < f32::EPSILON); - assert!((config.memory.soft_compaction_threshold - 0.60).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 8); -} - -#[test] -fn tools_summarize_output_default_true() { - let config = Config::default(); - assert!(config.tools.summarize_output); -} - -#[test] -#[serial] -fn env_override_tools_summarize_output() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.summarize_output); - - unsafe { std::env::set_var("ZEPH_TOOLS_SUMMARIZE_OUTPUT", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SUMMARIZE_OUTPUT") }; - - assert!(!config.tools.summarize_output); -} - -#[test] -#[serial] -fn auto_budget_default_true() { - clear_env(); - let config = Config::default(); - assert!(config.memory.auto_budget); -} - -#[test] -#[serial] -fn env_override_auto_budget() { - clear_env(); - let mut config = Config::default(); - assert!(config.memory.auto_budget); - - unsafe { std::env::set_var("ZEPH_MEMORY_AUTO_BUDGET", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_AUTO_BUDGET") }; - - assert!(!config.memory.auto_budget); -} - -#[test] -fn index_config_defaults() { - let config = Config::default(); - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); - assert!((config.index.score_threshold - 0.25).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 0.40).abs() < f32::EPSILON); - assert_eq!(config.index.repo_map_tokens, 500); -} - -#[test] -#[serial] -fn index_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("index.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[index] -enabled = true -max_chunks = 20 -score_threshold = 0.30 -budget_ratio = 0.50 -repo_map_tokens = 1000 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.index.enabled); - assert_eq!(config.index.max_chunks, 20); - assert!((config.index.score_threshold - 0.30).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 0.50).abs() < f32::EPSILON); - assert_eq!(config.index.repo_map_tokens, 1000); -} - -#[test] -#[serial] -fn index_config_missing_uses_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_index.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); -} - -#[test] -#[serial] -fn index_config_env_overrides() { - clear_env(); - let mut config = Config::default(); - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); - - unsafe { std::env::set_var("ZEPH_INDEX_ENABLED", "true") }; - unsafe { std::env::set_var("ZEPH_INDEX_MAX_CHUNKS", "24") }; - unsafe { std::env::set_var("ZEPH_INDEX_SCORE_THRESHOLD", "0.35") }; - unsafe { std::env::set_var("ZEPH_INDEX_BUDGET_RATIO", "0.60") }; - unsafe { std::env::set_var("ZEPH_INDEX_REPO_MAP_TOKENS", "750") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_INDEX_ENABLED") }; - unsafe { std::env::remove_var("ZEPH_INDEX_MAX_CHUNKS") }; - unsafe { std::env::remove_var("ZEPH_INDEX_SCORE_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_INDEX_BUDGET_RATIO") }; - unsafe { std::env::remove_var("ZEPH_INDEX_REPO_MAP_TOKENS") }; - - assert!(config.index.enabled); - assert_eq!(config.index.max_chunks, 24); - assert!((config.index.score_threshold - 0.35).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 0.60).abs() < f32::EPSILON); - assert_eq!(config.index.repo_map_tokens, 750); -} - -#[test] -#[serial] -fn index_config_env_overrides_clamped() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_INDEX_SCORE_THRESHOLD", "-0.5") }; - unsafe { std::env::set_var("ZEPH_INDEX_BUDGET_RATIO", "2.0") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_INDEX_SCORE_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_INDEX_BUDGET_RATIO") }; - - assert!((config.index.score_threshold - 0.0).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 1.0).abs() < f32::EPSILON); -} - -#[test] -#[serial] -fn index_config_env_override_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_INDEX_ENABLED", "not-a-bool") }; - unsafe { std::env::set_var("ZEPH_INDEX_MAX_CHUNKS", "abc") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_INDEX_ENABLED") }; - unsafe { std::env::remove_var("ZEPH_INDEX_MAX_CHUNKS") }; - - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); -} - -#[test] -fn security_config_default_autonomy_supervised() { - let config = Config::default(); - assert_eq!(config.security.autonomy_level, AutonomyLevel::Supervised); -} - -#[test] -fn discord_config_defaults() { - let config = Config::default(); - assert!(config.discord.is_none()); -} - -#[test] -#[serial] -fn parse_toml_with_autonomy_readonly() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("autonomy_readonly.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[security] -autonomy_level = "readonly" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.security.autonomy_level, AutonomyLevel::ReadOnly); -} - -#[test] -#[serial] -fn discord_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("discord.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[discord] -token = "discord-bot-token" -application_id = "12345" -allowed_user_ids = ["u1", "u2"] -allowed_role_ids = ["admin"] -allowed_channel_ids = ["ch1"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let dc = config.discord.unwrap(); - assert_eq!(dc.token.as_deref(), Some("discord-bot-token")); - assert_eq!(dc.application_id.as_deref(), Some("12345")); - assert_eq!(dc.allowed_user_ids, vec!["u1", "u2"]); - assert_eq!(dc.allowed_role_ids, vec!["admin"]); - assert_eq!(dc.allowed_channel_ids, vec!["ch1"]); -} - -#[test] -fn discord_debug_redacts_token() { - let dc = DiscordConfig { - token: Some("secret-discord-token".into()), - application_id: Some("app123".into()), - allowed_user_ids: vec![], - allowed_role_ids: vec![], - allowed_channel_ids: vec![], - }; - let debug = format!("{dc:?}"); - assert!(!debug.contains("secret-discord-token")); - assert!(debug.contains("[REDACTED]")); - assert!(debug.contains("app123")); -} - -#[test] -#[serial] -fn parse_toml_with_autonomy_full() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("autonomy_full.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[security] -autonomy_level = "full" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.security.autonomy_level, AutonomyLevel::Full); -} - -#[test] -#[serial] -fn discord_config_empty_allowlists() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("discord_empty.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[discord] -token = "tok" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let dc = config.discord.unwrap(); - assert!(dc.allowed_user_ids.is_empty()); - assert!(dc.allowed_role_ids.is_empty()); - assert!(dc.allowed_channel_ids.is_empty()); -} - -#[test] -fn slack_config_defaults() { - let config = Config::default(); - assert!(config.slack.is_none()); -} - -#[test] -#[serial] -fn slack_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("slack.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[slack] -bot_token = "xoxb-slack-token" -signing_secret = "slack-sign-secret" -port = 4000 -allowed_user_ids = ["U1"] -allowed_channel_ids = ["C1"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let sl = config.slack.unwrap(); - assert_eq!(sl.bot_token.as_deref(), Some("xoxb-slack-token")); - assert_eq!(sl.signing_secret.as_deref(), Some("slack-sign-secret")); - assert_eq!(sl.port, 4000); - assert_eq!(sl.allowed_user_ids, vec!["U1"]); - assert_eq!(sl.allowed_channel_ids, vec!["C1"]); -} - -#[test] -fn slack_config_default_port() { - let sl = SlackConfig { - bot_token: None, - signing_secret: None, - webhook_host: "127.0.0.1".into(), - port: 3000, - allowed_user_ids: vec![], - allowed_channel_ids: vec![], - }; - assert_eq!(sl.port, 3000); -} - -#[test] -fn slack_debug_redacts_tokens() { - let sl = SlackConfig { - bot_token: Some("xoxb-secret".into()), - signing_secret: Some("sign-secret".into()), - webhook_host: "127.0.0.1".into(), - port: 3000, - allowed_user_ids: vec![], - allowed_channel_ids: vec![], - }; - let debug = format!("{sl:?}"); - assert!(!debug.contains("xoxb-secret")); - assert!(!debug.contains("sign-secret")); - assert!(debug.contains("[REDACTED]")); - assert!(debug.contains("3000")); -} - -#[tokio::test] -async fn resolve_secrets_populates_discord_token() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_DISCORD_TOKEN", "dc-vault-token"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - let dc = config.discord.unwrap(); - assert_eq!(dc.token.as_deref(), Some("dc-vault-token")); -} - -#[tokio::test] -async fn resolve_secrets_populates_slack_tokens() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new() - .with_secret("ZEPH_SLACK_BOT_TOKEN", "xoxb-vault") - .with_secret("ZEPH_SLACK_SIGNING_SECRET", "sign-vault"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - let sl = config.slack.unwrap(); - assert_eq!(sl.bot_token.as_deref(), Some("xoxb-vault")); - assert_eq!(sl.signing_secret.as_deref(), Some("sign-vault")); -} - -#[tokio::test] -async fn resolve_secrets_populates_custom_map() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new() - .with_secret("ZEPH_SECRET_GITHUB_TOKEN", "gh-token-123") - .with_secret("ZEPH_SECRET_SOME_API_KEY", "api-val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_custom_secret(&config.secrets.custom, "github_token", "gh-token-123"); - assert_custom_secret(&config.secrets.custom, "some_api_key", "api-val"); -} - -#[tokio::test] -async fn resolve_secrets_custom_ignores_non_prefix_keys() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new() - .with_secret("ZEPH_CLAUDE_API_KEY", "claude-key") - .with_secret("OTHER_KEY", "other"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!(config.secrets.custom.is_empty()); -} - -#[tokio::test] -async fn resolve_secrets_hyphen_in_vault_key_normalized_to_underscore() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_SECRET_MY-KEY", "val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_custom_secret(&config.secrets.custom, "my_key", "val"); - assert!( - config.secrets.custom.get("my-key").is_none(), - "hyphenated key must not be stored" - ); -} - -#[tokio::test] -async fn resolve_secrets_bare_prefix_rejected() { - use crate::vault::MockVaultProvider; - // "ZEPH_SECRET_" with nothing after it — empty custom_name must be skipped - let vault = MockVaultProvider::new().with_secret("ZEPH_SECRET_", "val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!( - config.secrets.custom.is_empty(), - "bare ZEPH_SECRET_ prefix must not produce a custom entry" - ); -} - -#[tokio::test] -async fn resolve_secrets_get_secret_returns_none_skips_entry() { - use crate::vault::MockVaultProvider; - // Key is present in list_keys() but get_secret() returns None — entry must be skipped. - let vault = MockVaultProvider::new() - .with_listed_key("ZEPH_SECRET_GHOST") - .with_secret("ZEPH_SECRET_REAL", "val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!( - config.secrets.custom.get("ghost").is_none(), - "key with None get_secret must not appear in custom map" - ); - assert_custom_secret(&config.secrets.custom, "real", "val"); -} - -#[test] -fn stt_config_defaults() { - let toml_str = r" -[llm.stt] -"; - let stt: SttConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(stt.provider, "whisper"); - assert_eq!(stt.model, "whisper-1"); -} - -#[test] -fn stt_config_custom_values() { - let toml_str = r#" -provider = "custom" -model = "whisper-large-v3" -"#; - let stt: SttConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(stt.provider, "custom"); - assert_eq!(stt.model, "whisper-large-v3"); -} - -#[test] -fn llm_config_stt_none_by_default() { - let config = Config::default(); - assert!(config.llm.stt.is_none()); -} - -#[test] -#[serial] -fn env_override_stt_provider_and_model() { - clear_env(); - let mut config = Config::default(); - assert!(config.llm.stt.is_none()); - - unsafe { std::env::set_var("ZEPH_STT_PROVIDER", "candle-whisper") }; - unsafe { std::env::set_var("ZEPH_STT_MODEL", "openai/whisper-tiny") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_STT_PROVIDER") }; - unsafe { std::env::remove_var("ZEPH_STT_MODEL") }; - - let stt = config.llm.stt.unwrap(); - assert_eq!(stt.provider, "candle-whisper"); - assert_eq!(stt.model, "openai/whisper-tiny"); -} - -#[test] -#[serial] -fn env_override_stt_provider_only() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_STT_PROVIDER", "whisper") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_STT_PROVIDER") }; - - let stt = config.llm.stt.unwrap(); - assert_eq!(stt.provider, "whisper"); - assert_eq!(stt.model, "whisper-1"); -} - -#[test] -fn config_default_auto_update_check_is_true() { - let config = Config::default(); - assert!(config.agent.auto_update_check); -} - -#[test] -#[serial] -fn env_override_auto_update_check_false() { - clear_env(); - let mut config = Config::default(); - assert!(config.agent.auto_update_check); - - unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; - - assert!(!config.agent.auto_update_check); -} - -#[test] -#[serial] -fn env_override_auto_update_check_true() { - clear_env(); - let mut config = Config::default(); - config.agent.auto_update_check = false; - - unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; - - assert!(config.agent.auto_update_check); -} - -#[test] -#[serial] -fn env_override_auto_update_check_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - assert!(config.agent.auto_update_check); - - unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "not-a-bool") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; - - assert!(config.agent.auto_update_check); -} - -#[test] -fn vector_backend_sqlite_roundtrip() { - let toml_str = r#" -sqlite_path = "zeph.db" -history_limit = 100 -vector_backend = "sqlite" -"#; - let memory: MemoryConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(memory.vector_backend, VectorBackend::Sqlite); - - let serialized = toml::to_string(&memory).unwrap(); - let reparsed: MemoryConfig = toml::from_str(&serialized).unwrap(); - assert_eq!(reparsed.vector_backend, VectorBackend::Sqlite); -} - -#[test] -fn redact_credentials_false_parse() { - let toml_str = r#" -sqlite_path = "zeph.db" -history_limit = 100 -redact_credentials = false -"#; - let memory: MemoryConfig = toml::from_str(toml_str).unwrap(); - assert!(!memory.redact_credentials); -} - -#[test] -fn token_safety_margin_custom_parse() { - let toml_str = r#" -sqlite_path = "zeph.db" -history_limit = 100 -token_safety_margin = 1.15 -"#; - let memory: MemoryConfig = toml::from_str(toml_str).unwrap(); - assert!( - (memory.token_safety_margin - 1.15).abs() < 1e-5, - "token_safety_margin must parse to 1.15, got {}", - memory.token_safety_margin - ); -} - -#[test] -fn tool_call_cutoff_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.tool_call_cutoff = 0; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("tool_call_cutoff must be >= 1"), - "unexpected error: {err}" - ); -} - -#[test] -fn tool_call_cutoff_one_accepted_by_validate() { - let mut config = Config::default(); - config.memory.tool_call_cutoff = 1; - assert!(config.validate().is_ok()); -} - -#[test] -fn gateway_max_body_size_over_limit_rejected() { - let mut config = Config::default(); - config.gateway.max_body_size = 20_000_000; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("gateway.max_body_size must be <= 10485760"), - "unexpected error: {err}" - ); -} - -#[test] -fn gateway_max_body_size_at_limit_accepted() { - let mut config = Config::default(); - config.gateway.max_body_size = 10_485_760; - assert!(config.validate().is_ok()); -} - -// --- SEC-01: CompressionConfig validation tests --- - -#[test] -fn compression_threshold_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 0, - max_summary_tokens: 4_000, - }; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("compression.threshold_tokens must be >= 1000"), - "unexpected error: {err}" - ); -} - -#[test] -fn compression_threshold_below_minimum_rejected_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 999, - max_summary_tokens: 4_000, - }; - let result = config.validate(); - assert!(result.is_err()); -} - -#[test] -fn compression_threshold_at_minimum_accepted_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 1_000, - max_summary_tokens: 128, - }; - assert!(config.validate().is_ok()); -} - -#[test] -fn compression_max_summary_tokens_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 80_000, - max_summary_tokens: 0, - }; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("compression.max_summary_tokens must be >= 128"), - "unexpected error: {err}" - ); -} - -#[test] -fn compression_reactive_strategy_always_passes_validate() { - // Reactive strategy has no numeric fields, so validate should always pass. - let config = Config::default(); // Reactive by default - assert!( - matches!( - config.memory.compression.strategy, - CompressionStrategy::Reactive - ), - "default strategy should be Reactive" - ); - assert!(config.validate().is_ok()); -} - -#[test] -fn logging_config_defaults() { - let config = Config::default(); - assert_eq!(config.logging.file, default_log_file_path()); - assert_eq!(config.logging.level, "info"); - assert_eq!(config.logging.rotation, LogRotation::Daily); - assert_eq!(config.logging.max_files, 7); -} - -#[test] -#[serial] -fn logging_config_toml_deserialization() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - std::io::Write::write_all( - &mut f, - br#" -[agent] -name = "Test" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/test.db" -history_limit = 50 - -[logging] -file = "/tmp/zeph-test.log" -level = "debug" -rotation = "never" -max_files = 3 -"#, - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.logging.file, "/tmp/zeph-test.log"); - assert_eq!(config.logging.level, "debug"); - assert_eq!(config.logging.rotation, LogRotation::Never); - assert_eq!(config.logging.max_files, 3); -} - -#[test] -#[serial] -fn logging_config_empty_file_disables_file_logging() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - std::io::Write::write_all( - &mut f, - br#" -[agent] -name = "Test" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/test.db" -history_limit = 50 - -[logging] -file = "" -"#, - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.logging.file.is_empty()); -} - -#[test] -#[serial] -fn env_override_zeph_log_file() { - clear_env(); - let mut config = Config::default(); - unsafe { std::env::set_var("ZEPH_LOG_FILE", "/var/log/zeph.log") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LOG_FILE") }; - assert_eq!(config.logging.file, "/var/log/zeph.log"); -} - -#[test] -#[serial] -fn env_override_zeph_log_level() { - clear_env(); - let mut config = Config::default(); - unsafe { std::env::set_var("ZEPH_LOG_LEVEL", "warn") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LOG_LEVEL") }; - assert_eq!(config.logging.level, "warn"); -} - -#[test] -fn logging_rotation_serde_roundtrip() { - let daily: LogRotation = toml::from_str("rotation = \"daily\"") - .map(|t: toml::Table| { - t["rotation"] - .clone() - .try_into::() - .expect("deserialize daily") - }) - .expect("parse toml"); - assert_eq!(daily, LogRotation::Daily); - - let hourly: LogRotation = toml::from_str("rotation = \"hourly\"") - .map(|t: toml::Table| { - t["rotation"] - .clone() - .try_into::() - .expect("deserialize hourly") - }) - .expect("parse toml"); - assert_eq!(hourly, LogRotation::Hourly); - - let never: LogRotation = toml::from_str("rotation = \"never\"") - .map(|t: toml::Table| { - t["rotation"] - .clone() - .try_into::() - .expect("deserialize never") - }) - .expect("parse toml"); - assert_eq!(never, LogRotation::Never); -} - -// --- Threshold ordering validation tests --- - -#[test] -fn soft_above_hard_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.95; - config.memory.hard_compaction_threshold = 0.90; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold") && err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_equal_hard_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.90; - config.memory.hard_compaction_threshold = 0.90; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold") && err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_below_hard_accepted_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.70; - config.memory.hard_compaction_threshold = 0.90; - assert!(config.validate().is_ok()); -} - -#[test] -fn soft_compaction_threshold_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_compaction_threshold_one_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 1.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_compaction_threshold_negative_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = -0.1; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn hard_compaction_threshold_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.hard_compaction_threshold = 0.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn hard_compaction_threshold_one_rejected_by_validate() { - let mut config = Config::default(); - config.memory.hard_compaction_threshold = 1.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn hard_compaction_threshold_infinity_rejected_by_validate() { - let mut config = Config::default(); - config.memory.hard_compaction_threshold = f32::INFINITY; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_compaction_threshold_nan_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = f32::NAN; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -fn minimal_cloud_toml(extra_cloud: &str) -> String { - format!( - r#" -[agent] -name = "Zeph" - -[llm] -provider = "claude" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.cloud] -model = "claude-sonnet-4-6" -max_tokens = 8192 -{extra_cloud} -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) -} - -#[test] -#[serial] -fn server_compaction_defaults_to_false() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_sc.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!(f, "{}", minimal_cloud_toml("")).unwrap(); - clear_env(); - let config = Config::load(&path).unwrap(); - let cloud = config.llm.cloud.unwrap(); - assert!( - !cloud.server_compaction, - "server_compaction must default to false" - ); -} - -#[test] -#[serial] -fn server_compaction_parsed_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sc.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!(f, "{}", minimal_cloud_toml("server_compaction = true\n")).unwrap(); - clear_env(); - let config = Config::load(&path).unwrap(); - let cloud = config.llm.cloud.unwrap(); - assert!( - cloud.server_compaction, - "server_compaction must be true when set in TOML" - ); -} - -#[test] -#[serial] -fn parse_toml_gemini_with_thinking_level() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gemini_thinking.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "gemini" -base_url = "http://localhost:11434" -model = "gemini-3.0-flash" - -[llm.gemini] -model = "gemini-3.0-flash" -thinking_level = "medium" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let gemini = config.llm.gemini.unwrap(); - assert_eq!( - gemini.thinking_level, - Some(zeph_llm::GeminiThinkingLevel::Medium), - "thinking_level must parse from TOML lowercase value" - ); - assert!(gemini.thinking_budget.is_none()); - assert!(gemini.include_thoughts.is_none()); -} - -#[test] -#[serial] -fn parse_toml_gemini_with_thinking_budget() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gemini_budget.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "gemini" -base_url = "http://localhost:11434" -model = "gemini-2.5-flash" - -[llm.gemini] -model = "gemini-2.5-flash" -thinking_budget = 2048 -include_thoughts = true - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let gemini = config.llm.gemini.unwrap(); - assert_eq!(gemini.thinking_budget, Some(2048)); - assert_eq!(gemini.include_thoughts, Some(true)); - assert!(gemini.thinking_level.is_none()); -} - -#[test] -#[serial] -fn parse_toml_gemini_without_thinking_fields() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gemini_no_thinking.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "gemini" -base_url = "http://localhost:11434" -model = "gemini-2.0-flash" - -[llm.gemini] -model = "gemini-2.0-flash" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let gemini = config.llm.gemini.unwrap(); - assert!(gemini.thinking_level.is_none()); - assert!(gemini.thinking_budget.is_none()); - assert!(gemini.include_thoughts.is_none()); -} - -// TC-01: McpOAuthConfig TOML deserialization with defaults. -#[test] -fn mcp_oauth_config_defaults() { - let toml_str = r#"enabled = true"#; - let cfg: crate::config::McpOAuthConfig = toml::from_str(toml_str).unwrap(); - assert!(cfg.enabled); - assert_eq!(cfg.callback_port, 18766); - assert_eq!(cfg.client_name, "Zeph"); - assert!(cfg.scopes.is_empty()); - assert!(matches!( - cfg.token_storage, - crate::config::OAuthTokenStorage::Vault - )); -} - -// TC-02: OAuthTokenStorage serde variants round-trip via JSON. -#[test] -fn oauth_token_storage_serde_vault() { - let s: crate::config::OAuthTokenStorage = serde_json::from_str(r#""vault""#).unwrap(); - assert!(matches!(s, crate::config::OAuthTokenStorage::Vault)); - let back = serde_json::to_string(&s).unwrap(); - assert_eq!(back, r#""vault""#); -} - -#[test] -fn oauth_token_storage_serde_memory() { - let s: crate::config::OAuthTokenStorage = serde_json::from_str(r#""memory""#).unwrap(); - assert!(matches!(s, crate::config::OAuthTokenStorage::Memory)); - let back = serde_json::to_string(&s).unwrap(); - assert_eq!(back, r#""memory""#); -} - -// TC-03: Config::validate() rejects headers + oauth simultaneously. -#[test] -fn validate_rejects_headers_and_oauth_together() { - use std::collections::HashMap; - let mut config = Config::default(); - let mut headers = HashMap::new(); - headers.insert("Authorization".to_owned(), "Bearer tok".to_owned()); - config.mcp.servers.push(crate::config::McpServerConfig { - id: "srv".into(), - command: None, - args: vec![], - env: HashMap::new(), - url: Some("https://mcp.example.com".into()), - timeout: 30, - policy: zeph_mcp::McpPolicy::default(), - headers, - oauth: Some(crate::config::McpOAuthConfig { - enabled: true, - ..Default::default() - }), - }); - let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("cannot use both")); -} - -// TC-04: Config::validate() detects vault key collision. -#[test] -fn validate_detects_vault_key_collision() { - use std::collections::HashMap; - let mut config = Config::default(); - // Two servers with IDs that normalize to the same vault key. - for id in &["my-server", "my_server"] { - config.mcp.servers.push(crate::config::McpServerConfig { - id: (*id).to_owned(), - command: None, - args: vec![], - env: HashMap::new(), - url: Some("https://mcp.example.com".into()), - timeout: 30, - policy: zeph_mcp::McpPolicy::default(), - headers: HashMap::new(), - oauth: Some(crate::config::McpOAuthConfig { - enabled: true, - ..Default::default() - }), - }); - } - let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("vault key collision")); -} diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index 0016259a..61b931e9 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -62,7 +62,10 @@ pub use zeph_tools::executor::DiffData; // Re-export vault module to preserve internal import paths (e.g., `crate::vault::VaultProvider`). pub mod vault { pub use zeph_vault::{ - AgeVaultError, AgeVaultProvider, ArcAgeVaultProvider, EnvVaultProvider, - MockVaultProvider, Secret, VaultError, VaultProvider, default_vault_dir, + AgeVaultError, AgeVaultProvider, ArcAgeVaultProvider, EnvVaultProvider, Secret, + VaultError, VaultProvider, default_vault_dir, }; + + #[cfg(any(test, feature = "mock"))] + pub use zeph_vault::MockVaultProvider; } diff --git a/crates/zeph-core/src/vault.rs b/crates/zeph-core/src/vault.rs deleted file mode 100644 index 7b5ad07d..00000000 --- a/crates/zeph-core/src/vault.rs +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Andrei G -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Re-exports from zeph-vault to preserve internal import paths across the extraction. -pub use zeph_vault::*; From c57e6f1c4664313c65be1646c8ae63e47187b8dc Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 00:59:18 +0100 Subject: [PATCH 07/10] style: apply cargo fmt --- crates/zeph-core/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index 61b931e9..fbbf11f7 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -62,8 +62,8 @@ pub use zeph_tools::executor::DiffData; // Re-export vault module to preserve internal import paths (e.g., `crate::vault::VaultProvider`). pub mod vault { pub use zeph_vault::{ - AgeVaultError, AgeVaultProvider, ArcAgeVaultProvider, EnvVaultProvider, Secret, - VaultError, VaultProvider, default_vault_dir, + AgeVaultError, AgeVaultProvider, ArcAgeVaultProvider, EnvVaultProvider, Secret, VaultError, + VaultProvider, default_vault_dir, }; #[cfg(any(test, feature = "mock"))] From 89838d8fd19dd1522d89313246f5410f3b739f3c Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 01:08:23 +0100 Subject: [PATCH 08/10] fix: add mock feature flag to zeph-core The vault re-export module uses #[cfg(any(test, feature = "mock"))], which requires the feature to be declared in zeph-core's Cargo.toml. This also propagates the mock feature from zeph-vault. --- crates/zeph-core/Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/zeph-core/Cargo.toml b/crates/zeph-core/Cargo.toml index 09cb8ba3..39b1f238 100644 --- a/crates/zeph-core/Cargo.toml +++ b/crates/zeph-core/Cargo.toml @@ -19,8 +19,9 @@ compression-guidelines = ["zeph-memory/compression-guidelines", "zeph-config/com cuda = ["zeph-llm/cuda"] experiments = ["dep:ordered-float", "zeph-memory/experiments", "zeph-config/experiments"] guardrail = ["zeph-config/guardrail"] -metal = ["zeph-llm/metal"] lsp-context = ["zeph-config/lsp-context"] +metal = ["zeph-llm/metal"] +mock = ["zeph-vault/mock"] policy-enforcer = ["zeph-tools/policy-enforcer", "zeph-config/policy-enforcer"] scheduler = [] context-compression = [] From 73681ed31779245e1ce96c35b822d0ab7b874833 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 01:13:43 +0100 Subject: [PATCH 09/10] fix: import AutonomyLevel from zeph_tools AutonomyLevel is defined in zeph_tools, not zeph_config. Update the integration test import to reference it from the correct crate. --- tests/integration.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration.rs b/tests/integration.rs index d52d8693..c3b0abc8 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use zeph_core::agent::Agent; use zeph_core::channel::{Channel, ChannelError, ChannelMessage}; -use zeph_core::config::{AutonomyLevel, Config, ProviderKind, SecurityConfig, TimeoutConfig}; +use zeph_core::config::{Config, ProviderKind, SecurityConfig, TimeoutConfig}; use zeph_llm::any::AnyProvider; use zeph_llm::mock::MockProvider; use zeph_memory::semantic::SemanticMemory; @@ -16,6 +16,7 @@ use zeph_memory::sqlite::SqliteStore; use zeph_skills::loader::load_skill; use zeph_skills::registry::SkillRegistry; use zeph_tools::executor::{ToolError, ToolExecutor, ToolOutput}; +use zeph_tools::AutonomyLevel; // -- Provider helpers -- From d6f88b62fb16015dccd3dc4f56824c4dfc5db947 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Mar 2026 01:14:56 +0100 Subject: [PATCH 10/10] style: apply cargo fmt --- tests/integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration.rs b/tests/integration.rs index c3b0abc8..8f88f2da 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -15,8 +15,8 @@ use zeph_memory::semantic::SemanticMemory; use zeph_memory::sqlite::SqliteStore; use zeph_skills::loader::load_skill; use zeph_skills::registry::SkillRegistry; -use zeph_tools::executor::{ToolError, ToolExecutor, ToolOutput}; use zeph_tools::AutonomyLevel; +use zeph_tools::executor::{ToolError, ToolExecutor, ToolOutput}; // -- Provider helpers --