Skip to main content

hardware_enclave/internal/core/
timeout.rs

1#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
2//! Timeout utilities for subprocess execution and blocking reads.
3//!
4//! Cross-platform helpers to prevent enclave apps from hanging on
5//! unresponsive subprocesses, bridge calls, or slow OS operations.
6
7use std::io::{self, BufRead, BufReader, Read};
8use std::process::{Child, ExitStatus, Output, Stdio};
9use std::sync::mpsc;
10use std::thread;
11use std::time::{Duration, Instant};
12
13/// Result of a bounded subprocess operation.
14#[derive(Debug)]
15pub enum TimeoutResult<T> {
16    /// Operation completed within the deadline.
17    Completed(T),
18    /// Operation exceeded the deadline and the child was killed.
19    TimedOut,
20}
21
22impl<T> TimeoutResult<T> {
23    pub fn into_option(self) -> Option<T> {
24        match self {
25            TimeoutResult::Completed(v) => Some(v),
26            TimeoutResult::TimedOut => None,
27        }
28    }
29
30    pub fn is_timed_out(&self) -> bool {
31        matches!(self, TimeoutResult::TimedOut)
32    }
33}
34
35/// Poll interval for `try_wait`-based timeout loops.
36const POLL_INTERVAL: Duration = Duration::from_millis(50);
37
38/// Wait for a child process to exit, or return `TimedOut` after `timeout`
39/// elapses. On timeout, the caller is responsible for killing the child.
40pub fn wait_with_timeout(
41    child: &mut Child,
42    timeout: Duration,
43) -> io::Result<TimeoutResult<ExitStatus>> {
44    let start = Instant::now();
45    loop {
46        match child.try_wait()? {
47            Some(status) => return Ok(TimeoutResult::Completed(status)),
48            None => {
49                if start.elapsed() >= timeout {
50                    return Ok(TimeoutResult::TimedOut);
51                }
52                thread::sleep(POLL_INTERVAL);
53            }
54        }
55    }
56}
57
58/// Run a child to completion, collecting stdout/stderr, bounded by `timeout`.
59/// On timeout the child is killed and `TimedOut` is returned.
60///
61/// The child must already be configured via `.stdout(Stdio::piped())` etc.
62/// if you want to capture output.
63pub fn wait_output_with_timeout(
64    mut child: Child,
65    timeout: Duration,
66) -> io::Result<TimeoutResult<Output>> {
67    // Drain stdout/stderr on threads so the child's OS pipe buffers don't
68    // fill up and deadlock before we hit the timeout.
69    let stdout_thread = child.stdout.take().map(|mut s| {
70        thread::Builder::new()
71            .name("enclaveapp-child-stdout".into())
72            .spawn(move || -> io::Result<Vec<u8>> {
73                let mut buf = Vec::new();
74                s.read_to_end(&mut buf)?;
75                Ok(buf)
76            })
77    });
78    let stderr_thread = child.stderr.take().map(|mut s| {
79        thread::Builder::new()
80            .name("enclaveapp-child-stderr".into())
81            .spawn(move || -> io::Result<Vec<u8>> {
82                let mut buf = Vec::new();
83                s.read_to_end(&mut buf)?;
84                Ok(buf)
85            })
86    });
87
88    match wait_with_timeout(&mut child, timeout)? {
89        TimeoutResult::Completed(status) => {
90            let stdout = match stdout_thread {
91                Some(Ok(t)) => t.join().unwrap_or_else(|_| Ok(Vec::new()))?,
92                _ => Vec::new(),
93            };
94            let stderr = match stderr_thread {
95                Some(Ok(t)) => t.join().unwrap_or_else(|_| Ok(Vec::new()))?,
96                _ => Vec::new(),
97            };
98            Ok(TimeoutResult::Completed(Output {
99                status,
100                stdout,
101                stderr,
102            }))
103        }
104        TimeoutResult::TimedOut => {
105            drop(child.kill());
106            drop(child.wait());
107            Ok(TimeoutResult::TimedOut)
108        }
109    }
110}
111
112/// Spawn a command with piped stdout/stderr and run it to completion
113/// bounded by `timeout`.
114pub fn run_with_timeout(
115    mut cmd: std::process::Command,
116    timeout: Duration,
117) -> io::Result<TimeoutResult<Output>> {
118    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
119    let child = cmd.spawn()?;
120    wait_output_with_timeout(child, timeout)
121}
122
123/// Spawn a command (inheriting stdout/stderr) and wait for its exit status
124/// bounded by `timeout`. Kills the child on timeout.
125pub fn run_status_with_timeout(
126    mut cmd: std::process::Command,
127    timeout: Duration,
128) -> io::Result<TimeoutResult<ExitStatus>> {
129    let mut child = cmd.spawn()?;
130    match wait_with_timeout(&mut child, timeout)? {
131        TimeoutResult::Completed(status) => Ok(TimeoutResult::Completed(status)),
132        TimeoutResult::TimedOut => {
133            drop(child.kill());
134            drop(child.wait());
135            Ok(TimeoutResult::TimedOut)
136        }
137    }
138}
139
140/// Blocking reader of `read_line` with a timeout. Spawns a worker thread
141/// that owns the reader and sends each line over a channel.
142///
143/// The worker continues reading until EOF/error and cannot be cancelled
144/// once started — intended for cases where the reader is owned by the
145/// caller for the remainder of the session.
146#[derive(Debug)]
147pub struct LineReaderWithTimeout {
148    rx: mpsc::Receiver<io::Result<String>>,
149    _thread: thread::JoinHandle<()>,
150}
151
152impl LineReaderWithTimeout {
153    /// Build a line reader with no per-line size cap. The worker reads
154    /// until a newline regardless of length — only suitable for
155    /// readers under our own control. Untrusted-peer cases (the WSL
156    /// bridge, anything across a process boundary) should use
157    /// [`Self::with_max_line_bytes`] so a malicious or malfunctioning
158    /// peer can't drive unbounded heap allocation.
159    pub fn new<R: Read + Send + 'static>(reader: R) -> Self {
160        Self::spawn(reader, None)
161    }
162
163    /// Build a line reader that aborts (returns `InvalidData`) if a
164    /// single line exceeds `max_line_bytes` before its terminating
165    /// newline. Use this whenever the peer is across a trust boundary
166    /// — it bounds the worst-case allocation per line at
167    /// `max_line_bytes` rather than at the peer's discretion.
168    pub fn with_max_line_bytes<R: Read + Send + 'static>(reader: R, max_line_bytes: usize) -> Self {
169        Self::spawn(reader, Some(max_line_bytes))
170    }
171
172    fn spawn<R: Read + Send + 'static>(reader: R, max_line_bytes: Option<usize>) -> Self {
173        let (tx, rx) = mpsc::channel();
174        let thread = thread::Builder::new()
175            .name("enclaveapp-line-reader".into())
176            .spawn(move || {
177                let mut buf_reader = BufReader::new(reader);
178                loop {
179                    let result = match max_line_bytes {
180                        Some(max) => read_line_bounded(&mut buf_reader, max),
181                        None => {
182                            let mut line = String::new();
183                            match buf_reader.read_line(&mut line) {
184                                Ok(0) => Ok(None),
185                                Ok(_) => Ok(Some(line)),
186                                Err(e) => Err(e),
187                            }
188                        }
189                    };
190                    match result {
191                        Ok(None) => break, // EOF
192                        Ok(Some(line)) => {
193                            if tx.send(Ok(line)).is_err() {
194                                break;
195                            }
196                        }
197                        Err(e) => {
198                            drop(tx.send(Err(e)));
199                            break;
200                        }
201                    }
202                }
203            })
204            .expect("spawn line reader thread");
205        Self {
206            rx,
207            _thread: thread,
208        }
209    }
210
211    /// Receive the next line, or return `TimedOut` after `timeout`.
212    /// Returns `Completed(Err(_))` on read error and `Completed(Ok(""))`
213    /// on EOF.
214    pub fn recv_line(&self, timeout: Duration) -> TimeoutResult<io::Result<String>> {
215        match self.rx.recv_timeout(timeout) {
216            Ok(result) => TimeoutResult::Completed(result),
217            Err(mpsc::RecvTimeoutError::Timeout) => TimeoutResult::TimedOut,
218            Err(mpsc::RecvTimeoutError::Disconnected) => {
219                TimeoutResult::Completed(Ok(String::new()))
220            }
221        }
222    }
223}
224
225/// Read a single line into a `String`, returning `Ok(None)` on EOF
226/// before any byte arrives, `Ok(Some(line))` when a newline is hit
227/// (with the newline included, matching `BufRead::read_line`), or
228/// `Err` if the line exceeds `max_bytes` before a newline. The cap
229/// is on the line content excluding any oversize byte that wasn't
230/// consumed.
231///
232/// Public so it can be exercised by fuzz harnesses; production
233/// callers should normally use `LineReaderWithTimeout::with_max_line_bytes`
234/// instead, which adds the timeout-aware worker thread on top.
235pub fn read_line_bounded<R: BufRead>(
236    reader: &mut R,
237    max_bytes: usize,
238) -> io::Result<Option<String>> {
239    let mut buf: Vec<u8> = Vec::new();
240    loop {
241        let available = match reader.fill_buf() {
242            Ok(b) => b,
243            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
244            Err(e) => return Err(e),
245        };
246        if available.is_empty() {
247            // EOF
248            return if buf.is_empty() {
249                Ok(None)
250            } else {
251                String::from_utf8(buf)
252                    .map(Some)
253                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
254            };
255        }
256        // Up to `max_bytes` of remaining capacity, consume bytes
257        // through the next newline if one exists in that slice.
258        let remaining = max_bytes.saturating_sub(buf.len());
259        let usable = &available[..available.len().min(remaining + 1)];
260        if let Some(pos) = usable.iter().position(|&b| b == b'\n') {
261            buf.extend_from_slice(&usable[..=pos]);
262            reader.consume(pos + 1);
263            return String::from_utf8(buf)
264                .map(Some)
265                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e));
266        }
267        // No newline in the usable slice. Either we have headroom
268        // (remaining > 0) and just consume what we have, or we've
269        // hit the cap with no newline — that's a hard error, and we
270        // do NOT consume the offending bytes (so the caller could
271        // resync if they had any way to, though in practice the
272        // session is dead).
273        if remaining == 0 {
274            return Err(io::Error::new(
275                io::ErrorKind::InvalidData,
276                format!("line exceeds {max_bytes}-byte cap before newline"),
277            ));
278        }
279        let take = remaining.min(available.len());
280        buf.extend_from_slice(&available[..take]);
281        reader.consume(take);
282    }
283}
284
285#[cfg(test)]
286#[allow(clippy::unwrap_used, clippy::panic)]
287mod pure_tests {
288    use super::*;
289    use std::io::{self, Cursor};
290
291    #[test]
292    fn timeout_result_completed_into_option_is_some() {
293        let r: TimeoutResult<i32> = TimeoutResult::Completed(42);
294        assert_eq!(r.into_option(), Some(42));
295    }
296
297    #[test]
298    fn timeout_result_timed_out_into_option_is_none() {
299        let r: TimeoutResult<i32> = TimeoutResult::TimedOut;
300        assert_eq!(r.into_option(), None);
301    }
302
303    #[test]
304    fn timeout_result_completed_is_not_timed_out() {
305        let r: TimeoutResult<i32> = TimeoutResult::Completed(1);
306        assert!(!r.is_timed_out());
307    }
308
309    #[test]
310    fn timeout_result_timed_out_is_timed_out() {
311        let r: TimeoutResult<i32> = TimeoutResult::TimedOut;
312        assert!(r.is_timed_out());
313    }
314
315    #[test]
316    fn read_line_bounded_empty_reader_returns_none() {
317        let mut cursor = Cursor::new(b"");
318        let result = read_line_bounded(&mut cursor, 1024).unwrap();
319        assert!(result.is_none());
320    }
321
322    #[test]
323    fn read_line_bounded_single_line_with_newline() {
324        let mut cursor = Cursor::new(b"hello\n");
325        let result = read_line_bounded(&mut cursor, 1024).unwrap();
326        assert_eq!(result.as_deref(), Some("hello\n"));
327    }
328
329    #[test]
330    fn read_line_bounded_eof_without_newline() {
331        let mut cursor = Cursor::new(b"hello");
332        let result = read_line_bounded(&mut cursor, 1024).unwrap();
333        assert_eq!(result.as_deref(), Some("hello"));
334    }
335
336    #[test]
337    fn read_line_bounded_multiple_lines_reads_sequentially() {
338        let mut cursor = Cursor::new(b"first\nsecond\n");
339        let line1 = read_line_bounded(&mut cursor, 1024).unwrap();
340        let line2 = read_line_bounded(&mut cursor, 1024).unwrap();
341        let line3 = read_line_bounded(&mut cursor, 1024).unwrap();
342        assert_eq!(line1.as_deref(), Some("first\n"));
343        assert_eq!(line2.as_deref(), Some("second\n"));
344        assert!(line3.is_none());
345    }
346
347    #[test]
348    fn read_line_bounded_line_exceeds_cap_returns_invalid_data() {
349        // 5 chars before the newline, cap is 3 → InvalidData
350        let mut cursor = Cursor::new(b"hello\n");
351        let err = read_line_bounded(&mut cursor, 3).unwrap_err();
352        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
353    }
354
355    #[test]
356    fn read_line_bounded_line_at_exact_cap_succeeds() {
357        // exactly 5 chars before newline, cap is 5 → Ok
358        let mut cursor = Cursor::new(b"hello\n");
359        let result = read_line_bounded(&mut cursor, 5).unwrap();
360        assert_eq!(result.as_deref(), Some("hello\n"));
361    }
362
363    #[test]
364    fn read_line_bounded_max_bytes_zero_newline_first_succeeds() {
365        // cap=0, first byte is '\n' → consumed immediately → Ok("\n")
366        let mut cursor = Cursor::new(b"\nhello");
367        let result = read_line_bounded(&mut cursor, 0).unwrap();
368        assert_eq!(result.as_deref(), Some("\n"));
369    }
370
371    #[test]
372    fn read_line_bounded_max_bytes_zero_non_newline_first_is_error() {
373        let mut cursor = Cursor::new(b"a\nhello");
374        let err = read_line_bounded(&mut cursor, 0).unwrap_err();
375        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
376    }
377
378    #[test]
379    fn read_line_bounded_utf8_content() {
380        let input = "héllo\n";
381        let mut cursor = Cursor::new(input.as_bytes());
382        let result = read_line_bounded(&mut cursor, 128).unwrap();
383        assert_eq!(result.as_deref(), Some("héllo\n"));
384    }
385
386    #[test]
387    fn read_line_bounded_exactly_max_bytes_at_eof_no_newline() {
388        let mut cursor = Cursor::new(b"abc");
389        let result = read_line_bounded(&mut cursor, 3).unwrap();
390        assert_eq!(result.as_deref(), Some("abc"));
391    }
392
393    #[test]
394    fn read_line_bounded_large_cap_long_line() {
395        let line: Vec<u8> = std::iter::repeat(b'x').take(100).chain([b'\n']).collect();
396        let mut cursor = Cursor::new(line);
397        let result = read_line_bounded(&mut cursor, 200).unwrap();
398        let s = result.unwrap();
399        assert_eq!(s.len(), 101);
400        assert!(s.starts_with('x'));
401        assert!(s.ends_with('\n'));
402    }
403
404    #[test]
405    fn read_line_bounded_empty_line_newline_only() {
406        let mut cursor = Cursor::new(b"\n");
407        let result = read_line_bounded(&mut cursor, 1024).unwrap();
408        assert_eq!(result.as_deref(), Some("\n"));
409    }
410
411    #[test]
412    fn read_line_bounded_after_eof_returns_none() {
413        let mut cursor = Cursor::new(b"hi\n");
414        let _unused = read_line_bounded(&mut cursor, 1024).unwrap();
415        let eof = read_line_bounded(&mut cursor, 1024).unwrap();
416        assert!(eof.is_none());
417    }
418
419    #[test]
420    fn read_line_bounded_only_newlines() {
421        let mut cursor = Cursor::new(b"\n\n\n");
422        let r1 = read_line_bounded(&mut cursor, 10).unwrap();
423        let r2 = read_line_bounded(&mut cursor, 10).unwrap();
424        let r3 = read_line_bounded(&mut cursor, 10).unwrap();
425        let r4 = read_line_bounded(&mut cursor, 10).unwrap();
426        assert_eq!(r1.as_deref(), Some("\n"));
427        assert_eq!(r2.as_deref(), Some("\n"));
428        assert_eq!(r3.as_deref(), Some("\n"));
429        assert!(r4.is_none());
430    }
431
432    #[test]
433    fn read_line_bounded_single_char_at_eof() {
434        let mut cursor = Cursor::new(b"x");
435        let result = read_line_bounded(&mut cursor, 1).unwrap();
436        assert_eq!(result.as_deref(), Some("x"));
437    }
438
439    #[test]
440    fn read_line_bounded_error_message_contains_cap() {
441        let mut cursor = Cursor::new(b"toolongline\n");
442        let err = read_line_bounded(&mut cursor, 5).unwrap_err();
443        assert!(err.to_string().contains("5"));
444    }
445}
446
447#[cfg(all(test, unix))]
448#[allow(clippy::unwrap_used, clippy::panic, let_underscore_drop)]
449mod tests {
450    use super::*;
451    use std::process::Command;
452
453    #[cfg(unix)]
454    #[test]
455    fn run_with_timeout_completes_fast_command() {
456        let result = run_with_timeout(
457            {
458                let mut c = Command::new("/bin/sh");
459                c.args(["-c", "echo hello"]);
460                c
461            },
462            Duration::from_secs(5),
463        )
464        .unwrap();
465        match result {
466            TimeoutResult::Completed(output) => {
467                assert!(output.status.success());
468                assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hello");
469            }
470            TimeoutResult::TimedOut => panic!("fast command should not time out"),
471        }
472    }
473
474    #[cfg(unix)]
475    #[test]
476    fn run_with_timeout_kills_slow_command() {
477        let start = Instant::now();
478        let result = run_with_timeout(
479            {
480                let mut c = Command::new("/bin/sh");
481                c.args(["-c", "sleep 10"]);
482                c
483            },
484            Duration::from_millis(200),
485        )
486        .unwrap();
487        assert!(result.is_timed_out());
488        // Should fire well before the 10s sleep finishes
489        assert!(start.elapsed() < Duration::from_secs(2));
490    }
491
492    #[cfg(unix)]
493    #[test]
494    fn line_reader_delivers_line_within_timeout() {
495        use std::io::Write;
496        let mut cmd = Command::new("/bin/sh");
497        cmd.args(["-c", "cat"])
498            .stdin(Stdio::piped())
499            .stdout(Stdio::piped());
500        let mut child = cmd.spawn().unwrap();
501        let r = child.stdout.take().unwrap();
502        let mut w = child.stdin.take().unwrap();
503        let reader = LineReaderWithTimeout::new(r);
504        writeln!(w, "hello world").unwrap();
505        w.flush().unwrap();
506        match reader.recv_line(Duration::from_secs(2)) {
507            TimeoutResult::Completed(Ok(line)) => assert_eq!(line.trim(), "hello world"),
508            other => panic!("unexpected result: {:?}", other),
509        }
510        // Close stdin so cat exits, then reap the child to avoid a zombie.
511        drop(w);
512        drop(child.wait());
513    }
514
515    #[cfg(unix)]
516    #[test]
517    fn bounded_line_reader_aborts_when_line_exceeds_cap() {
518        // Feed 200 bytes followed by a newline through a bounded
519        // reader with a 100-byte cap. The reader must surface an
520        // InvalidData error rather than allocating the full 200.
521        //
522        // Use `seq 1 200 | xargs printf 'x%.0s'` rather than bash
523        // brace expansion (`{1..200}`). `/bin/sh` on Linux runners
524        // is `dash`, which doesn't expand `{1..200}` and produces a
525        // single `x` — leading to a flaky pass on macOS (where
526        // `/bin/sh` is bash) and a hard fail on the Linux CI runner.
527        use std::io::Write;
528        let mut cmd = Command::new("/bin/sh");
529        cmd.args(["-c", "seq 1 200 | xargs printf 'x%.0s' && printf '\\n'"])
530            .stdin(Stdio::null())
531            .stdout(Stdio::piped());
532        let mut child = cmd.spawn().unwrap();
533        let stdout = child.stdout.take().unwrap();
534        let reader = LineReaderWithTimeout::with_max_line_bytes(stdout, 100);
535        match reader.recv_line(Duration::from_secs(2)) {
536            TimeoutResult::Completed(Err(e)) => {
537                assert_eq!(e.kind(), io::ErrorKind::InvalidData);
538            }
539            other => panic!("expected InvalidData, got: {:?}", other),
540        }
541        drop(child.kill());
542        drop(child.wait());
543        let _ = Write::flush(&mut io::stdout());
544    }
545
546    #[cfg(unix)]
547    #[test]
548    fn bounded_line_reader_accepts_line_within_cap() {
549        use std::io::Write;
550        let mut cmd = Command::new("/bin/sh");
551        cmd.args(["-c", "printf 'short line\\n'"])
552            .stdin(Stdio::null())
553            .stdout(Stdio::piped());
554        let mut child = cmd.spawn().unwrap();
555        let stdout = child.stdout.take().unwrap();
556        let reader = LineReaderWithTimeout::with_max_line_bytes(stdout, 1024);
557        match reader.recv_line(Duration::from_secs(2)) {
558            TimeoutResult::Completed(Ok(line)) => assert_eq!(line, "short line\n"),
559            other => panic!("expected short line, got: {:?}", other),
560        }
561        drop(child.wait());
562        let _ = Write::flush(&mut io::stdout());
563    }
564
565    #[cfg(unix)]
566    #[test]
567    fn line_reader_times_out_when_no_data() {
568        let mut cmd = Command::new("/bin/sh");
569        cmd.args(["-c", "sleep 10"]).stdout(Stdio::piped());
570        let mut child = cmd.spawn().unwrap();
571        let stdout = child.stdout.take().unwrap();
572        let reader = LineReaderWithTimeout::new(stdout);
573        let start = Instant::now();
574        let result = reader.recv_line(Duration::from_millis(200));
575        assert!(result.is_timed_out());
576        assert!(start.elapsed() < Duration::from_secs(1));
577        drop(child.kill());
578        drop(child.wait());
579    }
580
581    #[cfg(unix)]
582    #[test]
583    fn run_status_with_timeout_completes_fast_command() {
584        let mut cmd = Command::new("/bin/sh");
585        cmd.args(["-c", "exit 0"]);
586        let result = run_status_with_timeout(cmd, Duration::from_secs(5)).unwrap();
587        match result {
588            TimeoutResult::Completed(status) => assert!(status.success()),
589            TimeoutResult::TimedOut => panic!("fast command should not time out"),
590        }
591    }
592
593    #[cfg(unix)]
594    #[test]
595    fn run_status_with_timeout_kills_slow_command() {
596        let start = Instant::now();
597        let mut cmd = Command::new("/bin/sh");
598        cmd.args(["-c", "sleep 10"]);
599        let result = run_status_with_timeout(cmd, Duration::from_millis(200)).unwrap();
600        assert!(result.is_timed_out());
601        assert!(start.elapsed() < Duration::from_secs(2));
602    }
603
604    #[test]
605    fn line_reader_eof_disconnects_and_returns_empty_string() {
606        // An immediately-empty reader causes the background thread to hit EOF
607        // and exit, dropping the sender. The next recv_line must return
608        // Completed(Ok("")) via the Disconnected arm rather than TimedOut.
609        let reader = LineReaderWithTimeout::new(io::Cursor::new(b""));
610        // Give the background thread time to reach EOF and exit.
611        thread::sleep(Duration::from_millis(50));
612        let result = reader.recv_line(Duration::from_millis(200));
613        assert!(
614            matches!(result, TimeoutResult::Completed(Ok(ref s)) if s.is_empty()),
615            "expected Completed(Ok(\"\")) after sender disconnect, got: {result:?}"
616        );
617    }
618}