mvm_runtime/
shell_mock.rs1#![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
13pub 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 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
47pub struct MockGuard;
49
50impl Drop for MockGuard {
51 fn drop(&mut self) {
52 HANDLER.with(|h| *h.borrow_mut() = None);
53 }
54}
55
56pub(crate) fn intercept(script: &str) -> Option<Output> {
58 HANDLER.with(|h| h.borrow().as_ref().map(|f| f(script).to_output()))
59}
60
61pub type SharedFs = Arc<Mutex<HashMap<String, String>>>;
63
64pub 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 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 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
96fn fs_handler(script: &str, fs: &SharedFs) -> MockResponse {
98 let s = script.trim();
99
100 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 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 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 if s.contains("test -L") && s.contains("echo yes") {
145 return MockResponse::ok("no");
146 }
147
148 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 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 if s.contains("rm -f ") {
195 return MockResponse::empty();
196 }
197
198 if s.contains("echo '") && s.contains("' >>") {
200 return MockResponse::empty();
201 }
202
203 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 MockResponse::empty()
222}
223
224pub 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
241pub 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}