1use fastshell::Fastshell;
20use std::sync::{Arc, Mutex};
21
22#[derive(Debug, Clone)]
24pub struct CmdOutput {
25 pub stdout: String,
26 pub stderr: String,
27 pub exit_code: i32,
28}
29
30pub trait ShellBackend: Send + Sync {
35 fn run(
36 &self,
37 command: &str,
38 stdin_input: Option<&str>,
39 timeout_secs: u64,
40 idle_timeout_secs: u64,
41 cwd: &std::path::Path,
42 ) -> CmdOutput;
43
44 fn kind(&self) -> &'static str;
46
47 fn ui_call(
50 &self,
51 _method: &str,
52 _args: &serde_json::Value,
53 ) -> Option<std::result::Result<serde_json::Value, String>> {
54 None
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum BackendKind {
61 Native,
63 Fastshell,
65}
66
67impl BackendKind {
68 pub fn platform_default() -> BackendKind {
71 if cfg!(any(target_os = "android", target_os = "ios")) {
72 BackendKind::Fastshell
73 } else {
74 BackendKind::Native
75 }
76 }
77}
78
79pub struct NativeShell;
83
84impl NativeShell {
85 pub fn new() -> Self {
86 NativeShell
87 }
88}
89
90impl Default for NativeShell {
91 fn default() -> Self {
92 NativeShell::new()
93 }
94}
95
96impl ShellBackend for NativeShell {
97 fn run(
98 &self,
99 command: &str,
100 stdin_input: Option<&str>,
101 timeout_secs: u64,
102 idle_timeout_secs: u64,
103 cwd: &std::path::Path,
104 ) -> CmdOutput {
105 use std::io::Write;
106 use std::process::{Command, Stdio};
107
108 let mut cmd = if cfg!(target_os = "windows") {
110 let mut c = Command::new("cmd");
111 c.arg("/C").arg(command);
112 c
113 } else {
114 let mut c = Command::new("sh");
115 c.arg("-c").arg(command);
116 c
117 };
118 cmd.current_dir(cwd)
119 .stdin(if stdin_input.is_some() {
120 Stdio::piped()
121 } else {
122 Stdio::null()
123 })
124 .stdout(Stdio::piped())
125 .stderr(Stdio::piped());
126
127 let mut child = match cmd.spawn() {
128 Ok(c) => c,
129 Err(e) => {
130 return CmdOutput {
131 stdout: String::new(),
132 stderr: format!("failed to spawn shell: {e}"),
133 exit_code: 127,
134 }
135 }
136 };
137
138 if let Some(input) = stdin_input {
140 if let Some(mut stdin) = child.stdin.take() {
141 let _ = stdin.write_all(input.as_bytes());
142 }
144 }
145
146 let output = if timeout_secs > 0 || idle_timeout_secs > 0 {
148 wait_with_timeout(child, timeout_secs, idle_timeout_secs)
149 } else {
150 child.wait_with_output().map(|o| CmdOutput {
151 stdout: String::from_utf8_lossy(&o.stdout).to_string(),
152 stderr: String::from_utf8_lossy(&o.stderr).to_string(),
153 exit_code: o.status.code().unwrap_or(-1),
154 })
155 };
156
157 output.unwrap_or_else(|e| CmdOutput {
158 stdout: String::new(),
159 stderr: format!("shell execution error: {e}"),
160 exit_code: -1,
161 })
162 }
163
164 fn kind(&self) -> &'static str {
165 "native"
166 }
167}
168
169fn wait_with_timeout(
173 mut child: std::process::Child,
174 timeout_secs: u64,
175 idle_timeout_secs: u64,
176) -> std::io::Result<CmdOutput> {
177 use std::io::Read;
178 use std::sync::mpsc;
179
180 let stdout_pipe = child.stdout.take();
181 let stderr_pipe = child.stderr.take();
182
183 enum Chunk {
184 Stdout(String),
185 Stderr(String),
186 }
187
188 let (chunk_tx, chunk_rx) = mpsc::channel::<Chunk>();
189
190 if let Some(mut p) = stdout_pipe {
193 let tx = chunk_tx.clone();
194 std::thread::spawn(move || {
195 let mut buf = [0u8; 4096];
196 loop {
197 match p.read(&mut buf) {
198 Ok(0) => break,
199 Ok(n) => {
200 let s = String::from_utf8_lossy(&buf[..n]).to_string();
201 let _ = tx.send(Chunk::Stdout(s));
202 }
203 Err(_) => break,
204 }
205 }
206 });
207 }
208 if let Some(mut p) = stderr_pipe {
209 let tx = chunk_tx.clone();
210 std::thread::spawn(move || {
211 let mut buf = [0u8; 4096];
212 loop {
213 match p.read(&mut buf) {
214 Ok(0) => break,
215 Ok(n) => {
216 let s = String::from_utf8_lossy(&buf[..n]).to_string();
217 let _ = tx.send(Chunk::Stderr(s));
218 }
219 Err(_) => break,
220 }
221 }
222 });
223 }
224 drop(chunk_tx);
225
226 let pid = child.id();
227 let (exit_tx, exit_rx) = mpsc::channel();
228 std::thread::spawn(move || {
229 let status = child.wait();
230 let _ = exit_tx.send(status);
231 });
232
233 let mut stdout = String::new();
234 let mut stderr = String::new();
235 let deadline = if timeout_secs > 0 {
236 Some(std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs))
237 } else {
238 None
239 };
240 let idle_dur = std::time::Duration::from_secs(idle_timeout_secs);
241 let mut last_data = std::time::Instant::now();
242
243 loop {
244 let wait_dur = match deadline {
246 Some(dl) => {
247 let remaining = dl.saturating_duration_since(std::time::Instant::now());
248 if idle_timeout_secs > 0 {
250 let idle_remaining = idle_dur.saturating_sub(last_data.elapsed());
251 remaining.min(idle_remaining)
252 } else {
253 remaining
254 }
255 }
256 None => {
257 if idle_timeout_secs > 0 {
258 idle_dur.saturating_sub(last_data.elapsed())
259 } else {
260 std::time::Duration::from_secs(u64::MAX)
262 }
263 }
264 };
265
266 if wait_dur.is_zero() {
267 let reason = if deadline.map_or(false, |dl| dl <= std::time::Instant::now()) {
269 format!("command timed out after {timeout_secs}s")
270 } else {
271 format!("command idle timeout after {idle_timeout_secs}s")
272 };
273 #[cfg(unix)]
274 unsafe {
275 libc::kill(pid as i32, libc::SIGKILL);
276 }
277 #[cfg(windows)]
278 {
279 let _ = std::process::Command::new("taskkill")
280 .args(["/F", "/T", "/PID", &pid.to_string()])
281 .output();
282 }
283 return Ok(CmdOutput {
284 stdout,
285 stderr: format!("{stderr}\n{reason}"),
286 exit_code: 124,
287 });
288 }
289
290 match chunk_rx.recv_timeout(wait_dur) {
292 Ok(Chunk::Stdout(s)) => {
293 last_data = std::time::Instant::now();
294 stdout.push_str(&s);
295 }
296 Ok(Chunk::Stderr(s)) => {
297 last_data = std::time::Instant::now();
298 stderr.push_str(&s);
299 }
300 Err(mpsc::RecvTimeoutError::Disconnected) => {
301 break;
303 }
304 Err(mpsc::RecvTimeoutError::Timeout) => {
305 if let Ok(Ok(status)) = exit_rx.try_recv() {
307 return Ok(CmdOutput {
308 stdout,
309 stderr,
310 exit_code: status.code().unwrap_or(-1),
311 });
312 }
313 continue;
316 }
317 }
318
319 if let Ok(Ok(status)) = exit_rx.try_recv() {
321 while let Ok(chunk) = chunk_rx.try_recv() {
323 match chunk {
324 Chunk::Stdout(s) => stdout.push_str(&s),
325 Chunk::Stderr(s) => stderr.push_str(&s),
326 }
327 }
328 return Ok(CmdOutput {
329 stdout,
330 stderr,
331 exit_code: status.code().unwrap_or(-1),
332 });
333 }
334 }
335
336 match exit_rx.recv() {
338 Ok(Ok(status)) => Ok(CmdOutput {
339 stdout,
340 stderr,
341 exit_code: status.code().unwrap_or(-1),
342 }),
343 Ok(Err(e)) => Err(e),
344 Err(_) => Ok(CmdOutput {
345 stdout,
346 stderr: format!("{stderr}\nprocess disappeared"),
347 exit_code: 127,
348 }),
349 }
350}
351
352pub type SharedFastshell = Arc<Mutex<Fastshell>>;
356
357pub struct FastshellBackend {
359 fs: SharedFastshell,
360 base_cwd: std::sync::Mutex<Option<String>>,
365}
366
367impl FastshellBackend {
368 pub fn new(fs: SharedFastshell) -> Self {
369 FastshellBackend {
370 fs,
371 base_cwd: std::sync::Mutex::new(None),
372 }
373 }
374}
375
376impl ShellBackend for FastshellBackend {
377 fn run(
378 &self,
379 command: &str,
380 stdin_input: Option<&str>,
381 timeout_secs: u64,
382 _idle_timeout_secs: u64,
383 _cwd: &std::path::Path,
384 ) -> CmdOutput {
385 let effective = match stdin_input {
389 Some(s) if !s.is_empty() => {
390 let escaped = s.replace('\\', "\\\\").replace('\'', "'\\''");
391 format!("printf '%s' '{escaped}' | {command}")
392 }
393 _ => command.to_string(),
394 };
395
396 let lock_timeout_secs = timeout_secs.max(5);
400 let timeout_ms = timeout_secs.saturating_mul(1000);
401 let fs_guard = {
402 let deadline =
403 std::time::Instant::now() + std::time::Duration::from_secs(lock_timeout_secs);
404 loop {
405 match self.fs.try_lock() {
406 Ok(g) => break g,
407 Err(std::sync::TryLockError::WouldBlock) => {
408 if std::time::Instant::now() > deadline {
409 return CmdOutput {
410 stdout: String::new(),
411 stderr: format!(
412 "fastshell: could not acquire lock after {lock_timeout_secs}s (busy)\n"
413 ),
414 exit_code: 124,
415 };
416 }
417 std::thread::sleep(std::time::Duration::from_millis(50));
418 }
419 Err(std::sync::TryLockError::Poisoned(e)) => break e.into_inner(),
420 }
421 }
422 };
423
424 let r = {
425 let base = {
427 let mut b = self.base_cwd.lock().unwrap_or_else(|e| e.into_inner());
428 if b.is_none() {
429 let pwd = fs_guard.execute("pwd").stdout.trim().to_string();
430 *b = Some(if pwd.is_empty() { "/".to_string() } else { pwd });
431 }
432 b.clone().unwrap_or_else(|| "/".to_string())
433 };
434 fs_guard.execute_in_with_timeout(&base, &effective, timeout_ms)
435 };
436
437 CmdOutput {
438 stdout: r.stdout,
439 stderr: r.stderr,
440 exit_code: r.exit_code,
441 }
442 }
443
444 fn kind(&self) -> &'static str {
445 "fastshell"
446 }
447
448 fn ui_call(
449 &self,
450 method: &str,
451 args: &serde_json::Value,
452 ) -> Option<std::result::Result<serde_json::Value, String>> {
453 let fs = self.fs.lock().ok()?;
454 Some(fs.ui_call(method, args))
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn platform_default_is_native_on_desktop() {
464 #[cfg(not(any(target_os = "android", target_os = "ios")))]
466 assert_eq!(BackendKind::platform_default(), BackendKind::Native);
467 }
468
469 #[test]
470 fn native_echo() {
471 let sh = NativeShell::new();
472 let out = sh.run("echo hello", None, 10, 0, std::path::Path::new("."));
473 assert_eq!(out.exit_code, 0);
474 assert!(out.stdout.contains("hello"));
475 }
476
477 #[test]
478 fn native_heredoc_works() {
479 let dir = std::env::temp_dir().join(format!("native_hd_{}", uuid::Uuid::new_v4().simple()));
481 std::fs::create_dir_all(&dir).unwrap();
482 let sh = NativeShell::new();
483 let out = sh.run(
484 "cat > f.txt << 'EOF'\nline1\nline2\nEOF\ncat f.txt",
485 None,
486 10,
487 0,
488 &dir,
489 );
490 assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
491 assert!(out.stdout.contains("line1"));
492 assert!(out.stdout.contains("line2"));
493 assert!(dir.join("f.txt").exists());
495 }
496
497 #[test]
498 fn native_real_cwd_and_pipes() {
499 let dir =
500 std::env::temp_dir().join(format!("native_cwd_{}", uuid::Uuid::new_v4().simple()));
501 std::fs::create_dir_all(&dir).unwrap();
502 std::fs::write(dir.join("data.txt"), "apple\nbanana\napple\n").unwrap();
503 let sh = NativeShell::new();
504 let out = sh.run("cat data.txt | grep apple | wc -l", None, 10, 0, &dir);
505 assert_eq!(out.exit_code, 0);
506 assert_eq!(out.stdout.trim(), "2");
507 }
508
509 #[test]
510 fn native_stdin_input() {
511 let sh = NativeShell::new();
512 let out = sh.run(
513 "cat",
514 Some("piped content"),
515 10,
516 0,
517 std::path::Path::new("."),
518 );
519 assert_eq!(out.exit_code, 0);
520 assert!(out.stdout.contains("piped content"));
521 }
522
523 #[test]
524 fn native_nonzero_exit() {
525 let sh = NativeShell::new();
526 let out = sh.run("exit 3", None, 10, 0, std::path::Path::new("."));
527 assert_eq!(out.exit_code, 3);
528 }
529
530 #[test]
531 fn native_timeout_kills() {
532 let sh = NativeShell::new();
533 let start = std::time::Instant::now();
534 let out = sh.run("sleep 10", None, 1, 0, std::path::Path::new("."));
538 let elapsed = start.elapsed();
539 assert!(
540 elapsed.as_secs() < 8,
541 "timeout should have fired; elapsed={elapsed:?}"
542 );
543 assert_eq!(
545 out.exit_code, 124,
546 "unexpected exit code; stdout={}, stderr={}",
547 out.stdout, out.stderr
548 );
549 }
550
551 #[test]
552 fn native_python_real() {
553 let sh = NativeShell::new();
555 let out = sh.run(
556 "python3 -c \"print(6*7)\"",
557 None,
558 10,
559 0,
560 std::path::Path::new("."),
561 );
562 if out.exit_code == 0 {
563 assert!(out.stdout.contains("42"));
564 }
565 }
566
567 #[test]
570 fn fastshell_backend_resets_cwd_between_calls() {
571 let dir =
572 std::env::temp_dir().join(format!("fs_backend_{}", uuid::Uuid::new_v4().simple()));
573 std::fs::create_dir_all(&dir).unwrap();
574 let mut fs = Fastshell::new();
575 let mut cfg = fastshell::Config::default();
576 cfg.sandbox_path = dir.to_string_lossy().to_string();
577 cfg.command_timeout_ms = 5_000;
578 fs.init(cfg).unwrap();
579 let backend = FastshellBackend::new(Arc::new(Mutex::new(fs)));
580
581 let base = backend
582 .run("pwd", None, 5, 0, &dir)
583 .stdout
584 .trim()
585 .to_string();
586 assert!(!base.is_empty(), "pwd should return the baseline cwd");
587
588 let _ = backend.run("cd ..", None, 5, 0, &dir);
590 let after = backend
591 .run("pwd", None, 5, 0, &dir)
592 .stdout
593 .trim()
594 .to_string();
595 assert_eq!(after, base, "cwd must reset to the baseline between calls");
596 }
597 #[test]
598 fn ui_call_without_plugin_returns_host_error() {
599 let fs = std::sync::Arc::new(std::sync::Mutex::new(Fastshell::new()));
600 let backend = FastshellBackend::new(fs);
601 match backend.ui_call("ui.backends", &serde_json::json!({})) {
602 Some(Err(e)) => assert!(e.contains("no host plugin"), "err={e}"),
603 other => panic!("unexpected: {other:?}"),
604 }
605 }
606}