Skip to main content

shape_runtime/stdlib_io/
process_ops.rs

1//! Process operation implementations for the io module.
2//!
3//! Exports: spawn, exec, process_wait, process_kill, process_write,
4//!          process_read, process_read_err, process_read_line
5
6use shape_value::ValueWord;
7use shape_value::heap_value::{IoHandleData, IoResource};
8use std::io::{BufRead, BufReader, Read, Write};
9use std::process::{Command, Stdio};
10use std::sync::Arc;
11
12/// io.spawn(cmd, args?) -> IoHandle (ChildProcess)
13///
14/// Spawn a subprocess with piped stdin/stdout/stderr.
15/// Returns a process handle. Use process_read/process_write/process_wait on it.
16pub fn io_spawn(
17    args: &[ValueWord],
18    ctx: &crate::module_exports::ModuleContext,
19) -> Result<ValueWord, String> {
20    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
21    let cmd = args
22        .first()
23        .and_then(|a| a.as_str())
24        .ok_or_else(|| "io.spawn() requires a command string".to_string())?;
25
26    let mut command = Command::new(cmd);
27
28    // Optional args array
29    if let Some(view) = args.get(1).and_then(|a| a.as_any_array()) {
30        let arr = view.to_generic();
31        for arg in arr.iter() {
32            if let Some(s) = arg.as_str() {
33                command.arg(s);
34            }
35        }
36    }
37
38    command
39        .stdin(Stdio::piped())
40        .stdout(Stdio::piped())
41        .stderr(Stdio::piped());
42
43    let child = command
44        .spawn()
45        .map_err(|e| format!("io.spawn(\"{}\"): {}", cmd, e))?;
46
47    let handle = IoHandleData::new_child_process(child, cmd.to_string());
48    Ok(ValueWord::from_io_handle(handle))
49}
50
51/// io.exec(cmd, args?) -> object { status: int, stdout: string, stderr: string }
52///
53/// Run a command to completion and capture its output.
54pub fn io_exec(
55    args: &[ValueWord],
56    ctx: &crate::module_exports::ModuleContext,
57) -> Result<ValueWord, String> {
58    crate::module_exports::check_permission(ctx, shape_abi_v1::Permission::Process)?;
59    let cmd = args
60        .first()
61        .and_then(|a| a.as_str())
62        .ok_or_else(|| "io.exec() requires a command string".to_string())?;
63
64    let mut command = Command::new(cmd);
65
66    if let Some(view) = args.get(1).and_then(|a| a.as_any_array()) {
67        let arr = view.to_generic();
68        for arg in arr.iter() {
69            if let Some(s) = arg.as_str() {
70                command.arg(s);
71            }
72        }
73    }
74
75    let output = command
76        .output()
77        .map_err(|e| format!("io.exec(\"{}\"): {}", cmd, e))?;
78
79    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
80    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
81    let status = output.status.code().unwrap_or(-1) as i64;
82
83    let pairs: Vec<(&str, ValueWord)> = vec![
84        ("status", ValueWord::from_i64(status)),
85        ("stdout", ValueWord::from_string(Arc::new(stdout))),
86        ("stderr", ValueWord::from_string(Arc::new(stderr))),
87    ];
88    Ok(crate::type_schema::typed_object_from_pairs(&pairs))
89}
90
91/// io.process_wait(handle) -> int (exit code)
92///
93/// Wait for a child process to exit and return its exit code.
94pub fn io_process_wait(
95    args: &[ValueWord],
96    _ctx: &crate::module_exports::ModuleContext,
97) -> Result<ValueWord, String> {
98    let handle = args
99        .first()
100        .and_then(|a| a.as_io_handle())
101        .ok_or_else(|| "io.process_wait() requires a ChildProcess IoHandle".to_string())?;
102
103    let mut guard = handle
104        .resource
105        .lock()
106        .map_err(|_| "io.process_wait(): lock poisoned".to_string())?;
107    let resource = guard
108        .as_mut()
109        .ok_or_else(|| "io.process_wait(): handle is closed".to_string())?;
110
111    match resource {
112        IoResource::ChildProcess(child) => {
113            let status = child
114                .wait()
115                .map_err(|e| format!("io.process_wait(): {}", e))?;
116            Ok(ValueWord::from_i64(status.code().unwrap_or(-1) as i64))
117        }
118        _ => Err("io.process_wait(): handle is not a ChildProcess".to_string()),
119    }
120}
121
122/// io.process_kill(handle) -> unit
123///
124/// Kill a running child process.
125pub fn io_process_kill(
126    args: &[ValueWord],
127    _ctx: &crate::module_exports::ModuleContext,
128) -> Result<ValueWord, String> {
129    let handle = args
130        .first()
131        .and_then(|a| a.as_io_handle())
132        .ok_or_else(|| "io.process_kill() requires a ChildProcess IoHandle".to_string())?;
133
134    let mut guard = handle
135        .resource
136        .lock()
137        .map_err(|_| "io.process_kill(): lock poisoned".to_string())?;
138    let resource = guard
139        .as_mut()
140        .ok_or_else(|| "io.process_kill(): handle is closed".to_string())?;
141
142    match resource {
143        IoResource::ChildProcess(child) => {
144            child
145                .kill()
146                .map_err(|e| format!("io.process_kill(): {}", e))?;
147            Ok(ValueWord::unit())
148        }
149        _ => Err("io.process_kill(): handle is not a ChildProcess".to_string()),
150    }
151}
152
153/// io.process_write(handle, data) -> int (bytes written)
154///
155/// Write to a child process's stdin. Takes the process handle directly;
156/// extracts the stdin pipe internally.
157pub fn io_process_write(
158    args: &[ValueWord],
159    _ctx: &crate::module_exports::ModuleContext,
160) -> Result<ValueWord, String> {
161    let handle = args
162        .first()
163        .and_then(|a| a.as_io_handle())
164        .ok_or_else(|| "io.process_write() requires a ChildProcess IoHandle".to_string())?;
165
166    let data = args
167        .get(1)
168        .and_then(|a| a.as_str())
169        .ok_or_else(|| "io.process_write() requires a string as second argument".to_string())?;
170
171    let mut guard = handle
172        .resource
173        .lock()
174        .map_err(|_| "io.process_write(): lock poisoned".to_string())?;
175    let resource = guard
176        .as_mut()
177        .ok_or_else(|| "io.process_write(): handle is closed".to_string())?;
178
179    match resource {
180        IoResource::ChildProcess(child) => {
181            let stdin = child
182                .stdin
183                .as_mut()
184                .ok_or_else(|| "io.process_write(): stdin pipe not available".to_string())?;
185            let written = stdin
186                .write(data.as_bytes())
187                .map_err(|e| format!("io.process_write(): {}", e))?;
188            Ok(ValueWord::from_i64(written as i64))
189        }
190        IoResource::PipeWriter(stdin) => {
191            let written = stdin
192                .write(data.as_bytes())
193                .map_err(|e| format!("io.process_write(): {}", e))?;
194            Ok(ValueWord::from_i64(written as i64))
195        }
196        _ => Err("io.process_write(): handle is not a ChildProcess or PipeWriter".to_string()),
197    }
198}
199
200/// io.process_read(handle, n?) -> string
201///
202/// Read from a child process's stdout. If n is given, read up to n bytes.
203pub fn io_process_read(
204    args: &[ValueWord],
205    _ctx: &crate::module_exports::ModuleContext,
206) -> Result<ValueWord, String> {
207    let handle = args
208        .first()
209        .and_then(|a| a.as_io_handle())
210        .ok_or_else(|| "io.process_read() requires a ChildProcess IoHandle".to_string())?;
211
212    let n = args
213        .get(1)
214        .and_then(|a| a.as_number_coerce())
215        .unwrap_or(65536.0) as usize;
216
217    let mut guard = handle
218        .resource
219        .lock()
220        .map_err(|_| "io.process_read(): lock poisoned".to_string())?;
221    let resource = guard
222        .as_mut()
223        .ok_or_else(|| "io.process_read(): handle is closed".to_string())?;
224
225    match resource {
226        IoResource::ChildProcess(child) => {
227            let stdout = child
228                .stdout
229                .as_mut()
230                .ok_or_else(|| "io.process_read(): stdout pipe not available".to_string())?;
231            let mut buf = vec![0u8; n];
232            let bytes_read = stdout
233                .read(&mut buf)
234                .map_err(|e| format!("io.process_read(): {}", e))?;
235            buf.truncate(bytes_read);
236            let s = String::from_utf8(buf)
237                .map_err(|e| format!("io.process_read(): invalid UTF-8: {}", e))?;
238            Ok(ValueWord::from_string(Arc::new(s)))
239        }
240        IoResource::PipeReader(stdout) => {
241            let mut buf = vec![0u8; n];
242            let bytes_read = stdout
243                .read(&mut buf)
244                .map_err(|e| format!("io.process_read(): {}", e))?;
245            buf.truncate(bytes_read);
246            let s = String::from_utf8(buf)
247                .map_err(|e| format!("io.process_read(): invalid UTF-8: {}", e))?;
248            Ok(ValueWord::from_string(Arc::new(s)))
249        }
250        _ => Err("io.process_read(): handle is not a ChildProcess or PipeReader".to_string()),
251    }
252}
253
254/// io.process_read_err(handle, n?) -> string
255///
256/// Read from a child process's stderr.
257pub fn io_process_read_err(
258    args: &[ValueWord],
259    _ctx: &crate::module_exports::ModuleContext,
260) -> Result<ValueWord, String> {
261    let handle = args
262        .first()
263        .and_then(|a| a.as_io_handle())
264        .ok_or_else(|| "io.process_read_err() requires a ChildProcess IoHandle".to_string())?;
265
266    let n = args
267        .get(1)
268        .and_then(|a| a.as_number_coerce())
269        .unwrap_or(65536.0) as usize;
270
271    let mut guard = handle
272        .resource
273        .lock()
274        .map_err(|_| "io.process_read_err(): lock poisoned".to_string())?;
275    let resource = guard
276        .as_mut()
277        .ok_or_else(|| "io.process_read_err(): handle is closed".to_string())?;
278
279    match resource {
280        IoResource::ChildProcess(child) => {
281            let stderr = child
282                .stderr
283                .as_mut()
284                .ok_or_else(|| "io.process_read_err(): stderr pipe not available".to_string())?;
285            let mut buf = vec![0u8; n];
286            let bytes_read = stderr
287                .read(&mut buf)
288                .map_err(|e| format!("io.process_read_err(): {}", e))?;
289            buf.truncate(bytes_read);
290            let s = String::from_utf8(buf)
291                .map_err(|e| format!("io.process_read_err(): invalid UTF-8: {}", e))?;
292            Ok(ValueWord::from_string(Arc::new(s)))
293        }
294        IoResource::PipeReaderErr(stderr) => {
295            let mut buf = vec![0u8; n];
296            let bytes_read = stderr
297                .read(&mut buf)
298                .map_err(|e| format!("io.process_read_err(): {}", e))?;
299            buf.truncate(bytes_read);
300            let s = String::from_utf8(buf)
301                .map_err(|e| format!("io.process_read_err(): invalid UTF-8: {}", e))?;
302            Ok(ValueWord::from_string(Arc::new(s)))
303        }
304        _ => {
305            Err("io.process_read_err(): handle is not a ChildProcess or PipeReaderErr".to_string())
306        }
307    }
308}
309
310/// io.process_read_line(handle) -> string
311///
312/// Read a single line from a child process's stdout (including newline).
313pub fn io_process_read_line(
314    args: &[ValueWord],
315    _ctx: &crate::module_exports::ModuleContext,
316) -> Result<ValueWord, String> {
317    let handle = args
318        .first()
319        .and_then(|a| a.as_io_handle())
320        .ok_or_else(|| "io.process_read_line() requires a ChildProcess IoHandle".to_string())?;
321
322    let mut guard = handle
323        .resource
324        .lock()
325        .map_err(|_| "io.process_read_line(): lock poisoned".to_string())?;
326    let resource = guard
327        .as_mut()
328        .ok_or_else(|| "io.process_read_line(): handle is closed".to_string())?;
329
330    match resource {
331        IoResource::ChildProcess(child) => {
332            let stdout = child
333                .stdout
334                .as_mut()
335                .ok_or_else(|| "io.process_read_line(): stdout pipe not available".to_string())?;
336            let mut line = String::new();
337            BufReader::new(stdout)
338                .read_line(&mut line)
339                .map_err(|e| format!("io.process_read_line(): {}", e))?;
340            Ok(ValueWord::from_string(Arc::new(line)))
341        }
342        IoResource::PipeReader(stdout) => {
343            let mut line = String::new();
344            BufReader::new(stdout)
345                .read_line(&mut line)
346                .map_err(|e| format!("io.process_read_line(): {}", e))?;
347            Ok(ValueWord::from_string(Arc::new(line)))
348        }
349        _ => Err("io.process_read_line(): handle is not a ChildProcess or PipeReader".to_string()),
350    }
351}
352
353/// io.stdin() -> IoHandle
354///
355/// Return an IoHandle for the current process's standard input.
356pub fn io_stdin(
357    _args: &[ValueWord],
358    _ctx: &crate::module_exports::ModuleContext,
359) -> Result<ValueWord, String> {
360    let file = std::fs::OpenOptions::new()
361        .read(true)
362        .open("/dev/stdin")
363        .map_err(|e| format!("io.stdin(): {}", e))?;
364    let handle = IoHandleData::new_file(file, "/dev/stdin".to_string(), "r".to_string());
365    Ok(ValueWord::from_io_handle(handle))
366}
367
368/// io.stdout() -> IoHandle
369///
370/// Return an IoHandle for the current process's standard output.
371pub fn io_stdout(
372    _args: &[ValueWord],
373    _ctx: &crate::module_exports::ModuleContext,
374) -> Result<ValueWord, String> {
375    let file = std::fs::OpenOptions::new()
376        .write(true)
377        .open("/dev/stdout")
378        .map_err(|e| format!("io.stdout(): {}", e))?;
379    let handle = IoHandleData::new_file(file, "/dev/stdout".to_string(), "w".to_string());
380    Ok(ValueWord::from_io_handle(handle))
381}
382
383/// io.stderr() -> IoHandle
384///
385/// Return an IoHandle for the current process's standard error.
386pub fn io_stderr(
387    _args: &[ValueWord],
388    _ctx: &crate::module_exports::ModuleContext,
389) -> Result<ValueWord, String> {
390    let file = std::fs::OpenOptions::new()
391        .write(true)
392        .open("/dev/stderr")
393        .map_err(|e| format!("io.stderr(): {}", e))?;
394    let handle = IoHandleData::new_file(file, "/dev/stderr".to_string(), "w".to_string());
395    Ok(ValueWord::from_io_handle(handle))
396}
397
398/// io.read_line(handle?) -> string
399///
400/// Read a line from an IoHandle (file or pipe). If no handle is given,
401/// reads from the current process's stdin.
402pub fn io_read_line(
403    args: &[ValueWord],
404    _ctx: &crate::module_exports::ModuleContext,
405) -> Result<ValueWord, String> {
406    // If a handle argument is provided, read from it
407    if let Some(handle) = args.first().and_then(|a| a.as_io_handle()) {
408        let mut guard = handle
409            .resource
410            .lock()
411            .map_err(|_| "io.read_line(): lock poisoned".to_string())?;
412        let resource = guard
413            .as_mut()
414            .ok_or_else(|| "io.read_line(): handle is closed".to_string())?;
415
416        match resource {
417            IoResource::File(file) => {
418                let mut line = String::new();
419                BufReader::new(file)
420                    .read_line(&mut line)
421                    .map_err(|e| format!("io.read_line(): {}", e))?;
422                Ok(ValueWord::from_string(Arc::new(line)))
423            }
424            IoResource::ChildProcess(child) => {
425                let stdout = child
426                    .stdout
427                    .as_mut()
428                    .ok_or_else(|| "io.read_line(): stdout pipe not available".to_string())?;
429                let mut line = String::new();
430                BufReader::new(stdout)
431                    .read_line(&mut line)
432                    .map_err(|e| format!("io.read_line(): {}", e))?;
433                Ok(ValueWord::from_string(Arc::new(line)))
434            }
435            IoResource::PipeReader(stdout) => {
436                let mut line = String::new();
437                BufReader::new(stdout)
438                    .read_line(&mut line)
439                    .map_err(|e| format!("io.read_line(): {}", e))?;
440                Ok(ValueWord::from_string(Arc::new(line)))
441            }
442            _ => Err("io.read_line(): handle does not support reading".to_string()),
443        }
444    } else {
445        // No handle: read from current process stdin
446        let mut line = String::new();
447        std::io::stdin()
448            .read_line(&mut line)
449            .map_err(|e| format!("io.read_line(): {}", e))?;
450        Ok(ValueWord::from_string(Arc::new(line)))
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
459        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
460        crate::module_exports::ModuleContext {
461            schemas: registry,
462            invoke_callable: None,
463            raw_invoker: None,
464            function_hashes: None,
465            vm_state: None,
466            granted_permissions: None,
467            scope_constraints: None,
468            set_pending_resume: None,
469            set_pending_frame_resume: None,
470        }
471    }
472
473    #[test]
474    fn test_exec_echo() {
475        let ctx = test_ctx();
476        let result = io_exec(
477            &[
478                ValueWord::from_string(Arc::new("echo".to_string())),
479                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
480                    "hello world".to_string(),
481                ))])),
482            ],
483            &ctx,
484        )
485        .unwrap();
486        assert_eq!(result.type_name(), "object");
487    }
488
489    #[test]
490    fn test_exec_false() {
491        let ctx = test_ctx();
492        let result = io_exec(
493            &[ValueWord::from_string(Arc::new("false".to_string()))],
494            &ctx,
495        )
496        .unwrap();
497        assert_eq!(result.type_name(), "object");
498    }
499
500    #[test]
501    fn test_exec_nonexistent() {
502        let ctx = test_ctx();
503        let result = io_exec(
504            &[ValueWord::from_string(Arc::new(
505                "__nonexistent_command_xyz__".to_string(),
506            ))],
507            &ctx,
508        );
509        assert!(result.is_err());
510    }
511
512    #[test]
513    fn test_spawn_and_wait() {
514        let ctx = test_ctx();
515        let handle = io_spawn(
516            &[
517                ValueWord::from_string(Arc::new("echo".to_string())),
518                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
519                    "test".to_string(),
520                ))])),
521            ],
522            &ctx,
523        )
524        .unwrap();
525        assert_eq!(handle.type_name(), "io_handle");
526
527        let code = io_process_wait(&[handle.clone()], &ctx).unwrap();
528        assert_eq!(code.as_number_coerce(), Some(0.0));
529    }
530
531    #[test]
532    fn test_spawn_read_stdout() {
533        let ctx = test_ctx();
534        let handle = io_spawn(
535            &[
536                ValueWord::from_string(Arc::new("echo".to_string())),
537                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
538                    "hello from process".to_string(),
539                ))])),
540            ],
541            &ctx,
542        )
543        .unwrap();
544
545        // Wait for process to finish first
546        io_process_wait(&[handle.clone()], &ctx).unwrap();
547
548        let output = io_process_read(&[handle.clone()], &ctx).unwrap();
549        let text = output.as_str().unwrap();
550        assert!(text.contains("hello from process"));
551
552        handle.as_io_handle().unwrap().close();
553    }
554
555    #[test]
556    fn test_spawn_write_stdin() {
557        let ctx = test_ctx();
558        // Use `cat` which echoes stdin to stdout
559        let handle =
560            io_spawn(&[ValueWord::from_string(Arc::new("cat".to_string()))], &ctx).unwrap();
561
562        // Write to stdin
563        let written = io_process_write(
564            &[
565                handle.clone(),
566                ValueWord::from_string(Arc::new("input data".to_string())),
567            ],
568            &ctx,
569        )
570        .unwrap();
571        assert!(written.as_number_coerce().unwrap() > 0.0);
572
573        // Close stdin to signal EOF to cat (drop the stdin pipe)
574        {
575            let h = handle.as_io_handle().unwrap();
576            let mut guard = h.resource.lock().unwrap();
577            if let Some(IoResource::ChildProcess(child)) = guard.as_mut() {
578                drop(child.stdin.take());
579            }
580        }
581
582        // Wait for process to finish
583        io_process_wait(&[handle.clone()], &ctx).unwrap();
584
585        // Read stdout
586        let output = io_process_read(&[handle.clone()], &ctx).unwrap();
587        assert_eq!(output.as_str().unwrap(), "input data");
588
589        handle.as_io_handle().unwrap().close();
590    }
591
592    #[test]
593    fn test_spawn_kill() {
594        let ctx = test_ctx();
595        // Start a long-running process
596        let handle = io_spawn(
597            &[
598                ValueWord::from_string(Arc::new("sleep".to_string())),
599                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
600                    "60".to_string(),
601                ))])),
602            ],
603            &ctx,
604        )
605        .unwrap();
606
607        // Kill it
608        let result = io_process_kill(&[handle.clone()], &ctx);
609        assert!(result.is_ok());
610
611        // Wait should return non-zero (signal)
612        let code = io_process_wait(&[handle.clone()], &ctx).unwrap();
613        // Killed processes typically have non-zero exit
614        let exit_code = code.as_number_coerce().unwrap() as i64;
615        assert!(exit_code != 0 || exit_code == -1);
616
617        handle.as_io_handle().unwrap().close();
618    }
619
620    #[test]
621    fn test_spawn_read_stderr() {
622        let ctx = test_ctx();
623        // Use a command that writes to stderr
624        let handle = io_spawn(
625            &[
626                ValueWord::from_string(Arc::new("sh".to_string())),
627                ValueWord::from_array(Arc::new(vec![
628                    ValueWord::from_string(Arc::new("-c".to_string())),
629                    ValueWord::from_string(Arc::new("echo error_msg >&2".to_string())),
630                ])),
631            ],
632            &ctx,
633        )
634        .unwrap();
635
636        io_process_wait(&[handle.clone()], &ctx).unwrap();
637
638        let err_output = io_process_read_err(&[handle.clone()], &ctx).unwrap();
639        let text = err_output.as_str().unwrap();
640        assert!(text.contains("error_msg"));
641
642        handle.as_io_handle().unwrap().close();
643    }
644
645    #[test]
646    fn test_stdout_handle() {
647        let ctx = test_ctx();
648        let handle = io_stdout(&[], &ctx).unwrap();
649        assert_eq!(handle.type_name(), "io_handle");
650        let data = handle.as_io_handle().unwrap();
651        assert_eq!(data.path, "/dev/stdout");
652        assert_eq!(data.mode, "w");
653        data.close();
654    }
655
656    #[test]
657    fn test_stderr_handle() {
658        let ctx = test_ctx();
659        let handle = io_stderr(&[], &ctx).unwrap();
660        assert_eq!(handle.type_name(), "io_handle");
661        let data = handle.as_io_handle().unwrap();
662        assert_eq!(data.path, "/dev/stderr");
663        assert_eq!(data.mode, "w");
664        data.close();
665    }
666
667    #[test]
668    fn test_read_line_from_pipe() {
669        let ctx = test_ctx();
670        // Spawn echo to produce a line, then read_line from the process handle
671        let handle = io_spawn(
672            &[
673                ValueWord::from_string(Arc::new("echo".to_string())),
674                ValueWord::from_array(Arc::new(vec![ValueWord::from_string(Arc::new(
675                    "line output".to_string(),
676                ))])),
677            ],
678            &ctx,
679        )
680        .unwrap();
681
682        io_process_wait(&[handle.clone()], &ctx).unwrap();
683
684        let line = io_process_read_line(&[handle.clone()], &ctx).unwrap();
685        assert!(line.as_str().unwrap().contains("line output"));
686
687        handle.as_io_handle().unwrap().close();
688    }
689}