Skip to main content

mvm_runtime/
shell_mock.rs

1//! Test mock for `shell::run_in_vm` and related functions.
2//!
3//! Provides a thread-local mock handler that intercepts shell commands
4//! during tests, backed by an in-memory filesystem simulation.
5#![allow(dead_code)]
6
7use std::cell::RefCell;
8use std::collections::HashMap;
9use std::os::unix::process::ExitStatusExt;
10use std::process::{ExitStatus, Output};
11use std::sync::{Arc, Mutex};
12
13/// Mock response for a shell command.
14pub struct MockResponse {
15    pub exit_code: i32,
16    pub stdout: String,
17}
18
19impl MockResponse {
20    pub fn ok(stdout: &str) -> Self {
21        Self {
22            exit_code: 0,
23            stdout: stdout.to_string(),
24        }
25    }
26
27    pub fn empty() -> Self {
28        Self::ok("")
29    }
30
31    pub(crate) fn to_output(&self) -> Output {
32        Output {
33            // Unix exit code encoding: status = code << 8
34            status: ExitStatus::from_raw(self.exit_code << 8),
35            stdout: self.stdout.as_bytes().to_vec(),
36            stderr: Vec::new(),
37        }
38    }
39}
40
41type MockHandler = Box<dyn Fn(&str) -> MockResponse>;
42
43thread_local! {
44    static HANDLER: RefCell<Option<MockHandler>> = const { RefCell::new(None) };
45}
46
47/// Guard that clears the mock handler on drop.
48pub struct MockGuard;
49
50impl Drop for MockGuard {
51    fn drop(&mut self) {
52        HANDLER.with(|h| *h.borrow_mut() = None);
53    }
54}
55
56/// Try to intercept a shell command via the installed mock handler.
57pub(crate) fn intercept(script: &str) -> Option<Output> {
58    HANDLER.with(|h| h.borrow().as_ref().map(|f| f(script).to_output()))
59}
60
61/// Shared reference to the in-memory filesystem backing the mock.
62pub type SharedFs = Arc<Mutex<HashMap<String, String>>>;
63
64/// Build a mock backed by an in-memory filesystem.
65pub fn mock_fs() -> MockFsBuilder {
66    MockFsBuilder {
67        files: HashMap::new(),
68    }
69}
70
71pub struct MockFsBuilder {
72    files: HashMap<String, String>,
73}
74
75impl MockFsBuilder {
76    /// Pre-populate a file.
77    pub fn with_file(mut self, path: &str, content: &str) -> Self {
78        self.files.insert(path.to_string(), content.to_string());
79        self
80    }
81
82    /// Install the mock. Returns a guard (clears on drop) and the shared fs.
83    pub fn install(self) -> (MockGuard, SharedFs) {
84        let fs = Arc::new(Mutex::new(self.files));
85        let fs_clone = fs.clone();
86
87        HANDLER.with(|h| {
88            let fs_ref = fs_clone.clone();
89            *h.borrow_mut() = Some(Box::new(move |script: &str| fs_handler(script, &fs_ref)));
90        });
91
92        (MockGuard, fs)
93    }
94}
95
96/// Handle a shell script using an in-memory filesystem.
97fn fs_handler(script: &str, fs: &SharedFs) -> MockResponse {
98    let s = script.trim();
99
100    // ── cat > path << 'MVMEOF'\ncontent\nMVMEOF ─────────────────────────
101    if s.contains("cat >") && s.contains("MVMEOF") {
102        if let Some(arrow) = s.find("cat > ") {
103            let after_cat = &s[arrow + 6..];
104            if let Some(space) = after_cat.find(" << ")
105                && let Some(start) = s.find("'MVMEOF'\n")
106            {
107                let path = after_cat[..space].trim();
108                let after_marker = &s[start + 9..];
109                if let Some(end) = after_marker.rfind("\nMVMEOF") {
110                    let content = &after_marker[..end];
111                    fs.lock()
112                        .unwrap()
113                        .insert(path.to_string(), content.to_string());
114                }
115            }
116        }
117        return MockResponse::empty();
118    }
119
120    // ── cat path (read file) ────────────────────────────────────────────
121    if s.starts_with("cat ") && !s.contains(">") && !s.contains("|") && !s.contains("<<") {
122        let path = s.strip_prefix("cat ").unwrap().trim();
123        if let Some(content) = fs.lock().unwrap().get(path) {
124            return MockResponse::ok(content);
125        }
126        return MockResponse {
127            exit_code: 1,
128            stdout: String::new(),
129        };
130    }
131
132    // ── test -f path && echo yes || echo no ─────────────────────────────
133    if s.contains("test -f ")
134        && s.contains("echo yes")
135        && let Some(idx) = s.find("test -f ")
136    {
137        let rest = &s[idx + 8..];
138        let path = rest.split_whitespace().next().unwrap_or("");
139        let exists = fs.lock().unwrap().contains_key(path);
140        return MockResponse::ok(if exists { "yes" } else { "no" });
141    }
142
143    // ── test -L (symlink check) — no symlinks in mock ───────────────────
144    if s.contains("test -L") && s.contains("echo yes") {
145        return MockResponse::ok("no");
146    }
147
148    // ── ls -1 path 2>/dev/null || true ──────────────────────────────────
149    if let Some(idx) = s.find("ls -1 ") {
150        let rest = &s[idx + 6..];
151        let path = rest
152            .split_whitespace()
153            .next()
154            .unwrap_or("")
155            .trim_end_matches('/');
156        let prefix = format!("{}/", path);
157
158        let fs_lock = fs.lock().unwrap();
159        let mut entries: Vec<String> = Vec::new();
160        for key in fs_lock.keys() {
161            if let Some(remainder) = key.strip_prefix(&prefix)
162                && let Some(name) = remainder.split('/').next()
163            {
164                let name = name.to_string();
165                if !entries.contains(&name) {
166                    entries.push(name);
167                }
168            }
169        }
170        entries.sort();
171        return MockResponse::ok(&entries.join("\n"));
172    }
173
174    // ── rm -rf path ─────────────────────────────────────────────────────
175    if s.contains("rm -rf ") {
176        for segment in s.split("rm -rf ").skip(1) {
177            let path = segment.split_whitespace().next().unwrap_or("").trim();
178            if !path.is_empty() {
179                let mut fs_lock = fs.lock().unwrap();
180                let to_remove: Vec<String> = fs_lock
181                    .keys()
182                    .filter(|k| k.starts_with(path))
183                    .cloned()
184                    .collect();
185                for key in to_remove {
186                    fs_lock.remove(&key);
187                }
188            }
189        }
190        return MockResponse::empty();
191    }
192
193    // ── rm -f (cleanup) ─────────────────────────────────────────────────
194    if s.contains("rm -f ") {
195        return MockResponse::empty();
196    }
197
198    // ── echo >> (audit log append) ──────────────────────────────────────
199    if s.contains("echo '") && s.contains("' >>") {
200        return MockResponse::empty();
201    }
202
203    // ── find ... instance.json ... grep guest_ip ────────────────────────
204    if s.contains("find ") && s.contains("instance.json") {
205        let fs_lock = fs.lock().unwrap();
206        let mut lines = Vec::new();
207        for (path, content) in fs_lock.iter() {
208            if path.ends_with("instance.json")
209                && let Ok(val) = serde_json::from_str::<serde_json::Value>(content)
210                && let Some(net) = val.get("net")
211                && let Some(ip) = net.get("guest_ip").and_then(|v| v.as_str())
212            {
213                lines.push(format!("  \"guest_ip\": \"{}\",", ip));
214            }
215        }
216        return MockResponse::ok(&lines.join("\n"));
217    }
218
219    // ── Default: succeed silently ───────────────────────────────────────
220    // Covers: mkdir, kill, sudo ip, iptables, curl, readlink, etc.
221    MockResponse::empty()
222}
223
224// ── Test fixture helpers ────────────────────────────────────────────────
225
226/// Generate a tenant config JSON string for use in tests.
227pub fn tenant_fixture(tenant_id: &str, net_id: u16, subnet: &str, gateway: &str) -> String {
228    let config = mvm_core::tenant::TenantConfig {
229        tenant_id: tenant_id.to_string(),
230        quotas: mvm_core::tenant::TenantQuota::default(),
231        net: mvm_core::tenant::TenantNet::new(net_id, subnet, gateway),
232        secrets_epoch: 0,
233        config_version: 1,
234        pinned: false,
235        audit_retention_days: 0,
236        created_at: "2025-01-01T00:00:00Z".to_string(),
237    };
238    serde_json::to_string_pretty(&config).unwrap()
239}
240
241/// Generate a pool spec JSON string for use in tests.
242pub fn pool_fixture(tenant_id: &str, pool_id: &str) -> String {
243    let spec = mvm_core::pool::PoolSpec {
244        pool_id: pool_id.to_string(),
245        tenant_id: tenant_id.to_string(),
246        flake_ref: ".".to_string(),
247        profile: "minimal".to_string(),
248        role: Default::default(),
249        instance_resources: mvm_core::pool::InstanceResources {
250            vcpus: 2,
251            mem_mib: 1024,
252            data_disk_mib: 0,
253        },
254        desired_counts: mvm_core::pool::DesiredCounts::default(),
255        runtime_policy: Default::default(),
256        metadata: mvm_core::pool::PoolMetadata::default(),
257        seccomp_policy: "baseline".to_string(),
258        snapshot_compression: "none".to_string(),
259        metadata_enabled: false,
260        pinned: false,
261        critical: false,
262        secret_scopes: vec![],
263        template_id: String::new(),
264    };
265    serde_json::to_string_pretty(&spec).unwrap()
266}