Skip to main content

sequel_mcp/app/
test_mode.rs

1//! `SEQUEL_MCP_TEST_MODE=1`: fail-closed isolation for test and benchmark
2//! runs. When active, the binary refuses to operate outside an explicitly
3//! supplied temporary root:
4//!
5//! * `SEQUEL_MCP_TEST_ROOT` is required; HOME, config, data, audit and
6//!   runtime paths must all resolve under it — otherwise the process
7//!   terminates BEFORE the MCP server starts (exit code 78).
8//! * MySQL endpoints must be loopback or explicitly allow-listed via
9//!   `SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS` (`host` or `host:port`, comma
10//!   separated) — checked before any connect attempt.
11//! * SQLite files must live under the root (`:memory:` excepted).
12//! * The production Keychain is unavailable; secrets come from the
13//!   in-memory store optionally seeded via `SEQUEL_MCP_TEST_SECRETS`
14//!   (`{"connection": {"user": "password"}}`, synthetic values only).
15//!
16//! Outside test mode none of these variables have any effect. This gate
17//! exists so a test/benchmark process can never silently inherit the
18//! developer's real configuration or reach a production server.
19
20use crate::vault::keychain::SecretStore as _;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24/// Exit code used when test-mode isolation is violated at startup
25/// (sysexits `EX_CONFIG`).
26pub const EXIT_ISOLATION: i32 = 78;
27
28pub fn is_active() -> bool {
29    std::env::var("SEQUEL_MCP_TEST_MODE").as_deref() == Ok("1")
30}
31
32fn root() -> Result<PathBuf, String> {
33    std::env::var_os("SEQUEL_MCP_TEST_ROOT")
34        .map(PathBuf::from)
35        .ok_or_else(|| {
36            "SEQUEL_MCP_TEST_MODE=1 requires SEQUEL_MCP_TEST_ROOT pointing at the isolated test tree"
37                .to_string()
38        })
39}
40
41fn under(path: &Path, root: &Path) -> bool {
42    path.starts_with(root)
43}
44
45/// Startup check: every derived writable path must live under the test
46/// root. Called before the MCP server starts; a violation terminates the
47/// process.
48pub fn verify_startup() -> Result<(), String> {
49    if !is_active() {
50        return Ok(());
51    }
52    let root = root()?;
53    let checks: [(&str, PathBuf); 6] = [
54        ("HOME", crate::app::paths::home_dir()),
55        ("config dir", crate::app::paths::config_dir()),
56        ("legacy config dir", crate::app::paths::legacy_config_dir()),
57        ("data dir", crate::app::paths::data_dir()),
58        ("audit db", crate::app::paths::audit_db_path()),
59        ("runtime dir", crate::app::paths::runtime_dir()),
60    ];
61    for (label, path) in checks {
62        if !under(&path, &root) {
63            return Err(format!(
64                "{label} resolves outside SEQUEL_MCP_TEST_ROOT: {} (root {})",
65                path.display(),
66                root.display()
67            ));
68        }
69    }
70    Ok(())
71}
72
73/// Endpoint allow-list entry forms: `host` (any port) or `host:port`.
74fn endpoint_allowed(host: &str, port: u16) -> bool {
75    let Ok(raw) = std::env::var("SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS") else {
76        return false;
77    };
78    raw.split(',').any(|entry| {
79        let entry = entry.trim();
80        if entry.is_empty() {
81            return false;
82        }
83        match entry.rsplit_once(':') {
84            Some((h, p)) => h == host && p.parse::<u16>() == Ok(port),
85            None => entry == host,
86        }
87    })
88}
89
90/// Refuse non-loopback MySQL endpoints BEFORE any connect attempt. In
91/// test mode the only legitimate targets are local (docker ports are
92/// published on loopback) or explicitly listed test endpoints.
93pub fn check_mysql_endpoint(host: &str, port: u16) -> Result<(), String> {
94    if !is_active() {
95        return Ok(());
96    }
97    let h = host.trim_matches(['[', ']']);
98    let loopback = matches!(h, "127.0.0.1" | "::1" | "localhost");
99    if loopback || endpoint_allowed(h, port) {
100        return Ok(());
101    }
102    Err(format!(
103        "MySQL endpoint {h}:{port} is not loopback and not in SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS; refusing before connect"
104    ))
105}
106
107/// Refuse SQLite database files outside the test root (`:memory:` is
108/// always allowed).
109pub fn check_sqlite_path(path: &Path) -> Result<(), String> {
110    if !is_active() {
111        return Ok(());
112    }
113    if path == Path::new(":memory:") {
114        return Ok(());
115    }
116    let root = root()?;
117    if under(path, &root) {
118        return Ok(());
119    }
120    Err(format!(
121        "SQLite path {} is outside SEQUEL_MCP_TEST_ROOT; refusing to open",
122        path.display()
123    ))
124}
125
126/// In-memory secret store for test mode, optionally seeded from
127/// `SEQUEL_MCP_TEST_SECRETS` (`{"connection": {"user": "password"}}`).
128/// The production Keychain is never consulted in test mode.
129pub fn secret_store() -> Arc<dyn crate::vault::keychain::SecretStore> {
130    let store = Arc::new(crate::vault::keychain::InMemorySecretStore::new());
131    if let Ok(raw) = std::env::var("SEQUEL_MCP_TEST_SECRETS")
132        && let Ok(map) = serde_json::from_str::<serde_json::Value>(&raw)
133        && let Some(obj) = map.as_object()
134    {
135        for (conn, users) in obj {
136            let Some(users) = users.as_object() else {
137                continue;
138            };
139            for (user, password) in users {
140                if let Some(password) = password.as_str() {
141                    let _ = store.set_password(conn, user, password);
142                }
143            }
144        }
145    }
146    store
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    // These tests manipulate the process environment, which is global;
154    // they must not run concurrently with each other. Rust runs unit
155    // tests in threads within one process, so guard with a lock.
156    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
157
158    fn set_var(key: &str, value: impl AsRef<std::ffi::OsStr>) {
159        // SAFETY: every caller holds ENV_LOCK; no other thread in this
160        // process reads these keys while the lock is held (the library
161        // code under test reads them synchronously below).
162        unsafe { std::env::set_var(key, value) };
163    }
164
165    fn remove_var(key: &str) {
166        // SAFETY: as above.
167        unsafe { std::env::remove_var(key) };
168    }
169
170    #[test]
171    fn inactive_by_default_and_allows_everything() {
172        let _g = ENV_LOCK.lock().unwrap();
173        remove_var("SEQUEL_MCP_TEST_MODE");
174        assert!(!is_active());
175        assert!(verify_startup().is_ok());
176        assert!(check_mysql_endpoint("db.prod.example.invalid", 3306).is_ok());
177        assert!(check_sqlite_path(Path::new("/tmp/anywhere.sqlite")).is_ok());
178    }
179
180    #[test]
181    fn active_requires_root_and_paths_under_it() {
182        let _g = ENV_LOCK.lock().unwrap();
183        set_var("SEQUEL_MCP_TEST_MODE", "1");
184        remove_var("SEQUEL_MCP_TEST_ROOT");
185        assert!(verify_startup().is_err());
186
187        let dir = tempfile::TempDir::new().unwrap();
188        set_var("SEQUEL_MCP_TEST_ROOT", dir.path());
189        set_var("HOME", dir.path().join("home"));
190        set_var("XDG_CONFIG_HOME", dir.path().join("config"));
191        set_var("XDG_DATA_HOME", dir.path().join("data"));
192        assert!(verify_startup().is_ok(), "all paths under the root");
193
194        // Point one path outside the root: startup must refuse.
195        set_var("XDG_DATA_HOME", "/definitely/outside");
196        let err = verify_startup().unwrap_err();
197        assert!(err.contains("outside SEQUEL_MCP_TEST_ROOT"), "{err}");
198        assert!(err.contains("data dir"), "{err}");
199
200        remove_var("SEQUEL_MCP_TEST_MODE");
201        remove_var("SEQUEL_MCP_TEST_ROOT");
202        remove_var("XDG_DATA_HOME");
203        remove_var("XDG_CONFIG_HOME");
204        remove_var("HOME");
205        let _ = dir;
206    }
207
208    #[test]
209    fn mysql_endpoint_gate() {
210        let _g = ENV_LOCK.lock().unwrap();
211        set_var("SEQUEL_MCP_TEST_MODE", "1");
212        let dir = tempfile::TempDir::new().unwrap();
213        set_var("SEQUEL_MCP_TEST_ROOT", dir.path());
214        remove_var("SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS");
215        assert!(check_mysql_endpoint("127.0.0.1", 3306).is_ok());
216        assert!(check_mysql_endpoint("localhost", 3307).is_ok());
217        assert!(check_mysql_endpoint("::1", 3306).is_ok());
218        let err = check_mysql_endpoint("192.0.2.10", 3306).unwrap_err();
219        assert!(
220            err.contains("192.0.2.10") && err.contains("refusing"),
221            "{err}"
222        );
223
224        set_var(
225            "SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS",
226            "db.test.example,10.1.2.3:3307",
227        );
228        assert!(check_mysql_endpoint("db.test.example", 3306).is_ok());
229        assert!(check_mysql_endpoint("10.1.2.3", 3307).is_ok());
230        assert!(check_mysql_endpoint("10.1.2.3", 3306).is_err());
231
232        remove_var("SEQUEL_MCP_TEST_MODE");
233        remove_var("SEQUEL_MCP_TEST_ROOT");
234        remove_var("SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS");
235        let _ = dir;
236    }
237
238    #[test]
239    fn sqlite_path_gate() {
240        let _g = ENV_LOCK.lock().unwrap();
241        set_var("SEQUEL_MCP_TEST_MODE", "1");
242        let dir = tempfile::TempDir::new().unwrap();
243        set_var("SEQUEL_MCP_TEST_ROOT", dir.path());
244        assert!(check_sqlite_path(Path::new(":memory:")).is_ok());
245        assert!(check_sqlite_path(&dir.path().join("a.sqlite")).is_ok());
246        assert!(check_sqlite_path(Path::new("/tmp/outside.sqlite")).is_err());
247        remove_var("SEQUEL_MCP_TEST_MODE");
248        remove_var("SEQUEL_MCP_TEST_ROOT");
249        let _ = dir;
250    }
251
252    #[test]
253    fn secret_store_seeds_from_env() {
254        let _g = ENV_LOCK.lock().unwrap();
255        set_var(
256            "SEQUEL_MCP_TEST_SECRETS",
257            r#"{"db": {"root": "synthetic-password"}}"#,
258        );
259        let store = secret_store();
260        assert_eq!(
261            store.get_password("db", "root").unwrap().as_str(),
262            "synthetic-password"
263        );
264        assert!(store.get_password("db", "other").is_err());
265        remove_var("SEQUEL_MCP_TEST_SECRETS");
266    }
267}