1use std::io::{self, Read};
2use std::path::Path;
3use std::process::{Command, Stdio};
4use std::sync::{
5 atomic::{AtomicBool, Ordering},
6 Arc,
7};
8use std::thread::{self, JoinHandle};
9use std::time::{Duration, Instant};
10
11#[cfg(unix)]
12use std::os::fd::AsRawFd;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::cancellation::CancellationToken;
18
19pub const COMMAND_TIMEOUT: Duration = Duration::from_secs(10 * 60);
20pub const COMMAND_OUTPUT_CAP: usize = 64 * 1024;
21const CAPTURE_SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct CmdResult {
25 pub command: String,
26 pub exit_code: Option<i32>,
27 pub timed_out: bool,
28 pub stdout: String,
29 pub stderr: String,
30 pub stdout_truncated: bool,
31 pub stderr_truncated: bool,
32 #[serde(default, skip_serializing_if = "is_false")]
33 pub canceled: bool,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub error: Option<String>,
36}
37
38impl CmdResult {
39 fn error(arguments: &str, message: impl Into<String>) -> Self {
40 Self {
41 command: arguments.to_owned(),
42 exit_code: None,
43 timed_out: false,
44 stdout: String::new(),
45 stderr: String::new(),
46 stdout_truncated: false,
47 stderr_truncated: false,
48 canceled: false,
49 error: Some(message.into()),
50 }
51 }
52
53 pub(crate) fn canceled(command: impl Into<String>, message: impl Into<String>) -> Self {
54 Self {
55 command: command.into(),
56 exit_code: None,
57 timed_out: false,
58 stdout: String::new(),
59 stderr: String::new(),
60 stdout_truncated: false,
61 stderr_truncated: false,
62 canceled: true,
63 error: Some(message.into()),
64 }
65 }
66}
67
68fn is_false(value: &bool) -> bool {
69 !*value
70}
71
72#[derive(Debug)]
73struct CapturedOutput {
74 bytes: Vec<u8>,
75 truncated: bool,
76}
77
78pub fn execute(arguments: &str, cwd: &Path, api_key_env: &str, secret: Option<&str>) -> CmdResult {
79 execute_with_cancellation(arguments, cwd, api_key_env, secret, None)
80}
81
82pub(crate) fn execute_with_cancellation(
83 arguments: &str,
84 cwd: &Path,
85 api_key_env: &str,
86 secret: Option<&str>,
87 cancellation: Option<&CancellationToken>,
88) -> CmdResult {
89 let value: Value = match serde_json::from_str(arguments) {
90 Ok(value) => value,
91 Err(_) => return CmdResult::error("{}", "cmd arguments must be a JSON object"),
92 };
93 let Some(object) = value.as_object() else {
94 return CmdResult::error("{}", "cmd arguments must be a JSON object");
95 };
96 if object.len() != 1 || !object.contains_key("command") {
97 return CmdResult::error("{}", "cmd arguments must contain only command");
98 }
99 let Some(command) = object.get("command").and_then(Value::as_str) else {
100 return CmdResult::error("{}", "cmd command must be a string");
101 };
102 if cancellation.is_some_and(|token| token.is_cancelled()) {
103 return CmdResult::canceled(
104 redact_secret(command, secret),
105 "command canceled before execution",
106 );
107 }
108 execute_command_with_cancellation(
109 command,
110 cwd,
111 api_key_env,
112 secret,
113 COMMAND_TIMEOUT,
114 COMMAND_OUTPUT_CAP,
115 cancellation,
116 )
117}
118
119pub fn execute_command(
120 command: &str,
121 cwd: &Path,
122 api_key_env: &str,
123 secret: Option<&str>,
124 timeout: Duration,
125 output_cap: usize,
126) -> CmdResult {
127 execute_command_with_cancellation(command, cwd, api_key_env, secret, timeout, output_cap, None)
128}
129
130pub(crate) fn execute_command_with_cancellation(
131 command: &str,
132 cwd: &Path,
133 api_key_env: &str,
134 secret: Option<&str>,
135 timeout: Duration,
136 output_cap: usize,
137 cancellation: Option<&CancellationToken>,
138) -> CmdResult {
139 if cancellation.is_some_and(|token| token.is_cancelled()) {
140 return CmdResult::canceled(
141 redact_secret(command, secret),
142 "command canceled before execution",
143 );
144 }
145
146 let mut process = Command::new("/bin/sh");
147 process
148 .arg("-lc")
149 .arg(command)
150 .current_dir(cwd)
151 .env_remove(api_key_env)
152 .stdin(Stdio::null())
153 .stdout(Stdio::piped())
154 .stderr(Stdio::piped());
155
156 #[cfg(unix)]
157 {
158 use std::os::unix::process::CommandExt;
159
160 unsafe {
163 process.pre_exec(|| {
164 if libc::setpgid(0, 0) == -1 {
165 return Err(io::Error::last_os_error());
166 }
167 Ok(())
168 });
169 }
170 }
171
172 let mut child = match process.spawn() {
173 Ok(child) => child,
174 Err(_) => {
175 return CmdResult {
176 command: redact_secret(command, secret),
177 exit_code: None,
178 timed_out: false,
179 stdout: String::new(),
180 stderr: String::new(),
181 stdout_truncated: false,
182 stderr_truncated: false,
183 canceled: false,
184 error: Some("unable to start command".to_owned()),
185 }
186 }
187 };
188
189 let capture_stop = Arc::new(AtomicBool::new(false));
190 let stdout_reader = child
191 .stdout
192 .take()
193 .map(|stdout| spawn_capture(stdout, output_cap, Arc::clone(&capture_stop)));
194 let stderr_reader = child
195 .stderr
196 .take()
197 .map(|stderr| spawn_capture(stderr, output_cap, Arc::clone(&capture_stop)));
198
199 let child_id = child.id();
200 let deadline = Instant::now() + timeout;
201 let mut timed_out = false;
202 let mut canceled = false;
203 let status = loop {
204 if cancellation.is_some_and(|token| token.is_cancelled()) {
205 canceled = true;
206 kill_process_group(child_id);
207 let _ = child.kill();
208 break child.wait().ok();
209 }
210 match child.try_wait() {
211 Ok(Some(status)) => {
212 if cancellation.is_some_and(|token| token.is_cancelled()) {
213 canceled = true;
214 }
215 break Some(status);
216 }
217 Ok(None) => {
218 if Instant::now() >= deadline {
219 timed_out = true;
220 kill_process_group(child_id);
221 let _ = child.kill();
222 break child.wait().ok();
223 }
224 thread::sleep(Duration::from_millis(10));
225 }
226 Err(_) => {
227 canceled = cancellation.is_some_and(|token| token.is_cancelled());
228 kill_process_group(child_id);
229 let _ = child.kill();
230 break child.wait().ok();
231 }
232 }
233 };
234
235 if !timed_out {
236 kill_process_group(child_id);
239 }
240
241 capture_stop.store(true, Ordering::Release);
242 let stdout_capture = join_capture(stdout_reader);
243 let stderr_capture = join_capture(stderr_reader);
244 let (stdout, stdout_truncated) = bounded_output(&stdout_capture, output_cap, secret);
245 let (stderr, stderr_truncated) = bounded_output(&stderr_capture, output_cap, secret);
246
247 CmdResult {
248 command: redact_secret(command, secret),
249 exit_code: (!canceled)
250 .then(|| status.and_then(|status| status.code()))
251 .flatten(),
252 timed_out,
253 stdout,
254 stderr,
255 stdout_truncated,
256 stderr_truncated,
257 canceled,
258 error: canceled.then_some("command canceled".to_owned()),
259 }
260}
261
262#[cfg(unix)]
263fn spawn_capture<R>(mut reader: R, cap: usize, stop: Arc<AtomicBool>) -> JoinHandle<CapturedOutput>
264where
265 R: Read + Send + AsRawFd + 'static,
266{
267 let _ = set_nonblocking(reader.as_raw_fd());
268 thread::spawn(move || capture_output(&mut reader, cap, &stop))
269}
270
271#[cfg(not(unix))]
272fn spawn_capture<R>(mut reader: R, cap: usize, stop: Arc<AtomicBool>) -> JoinHandle<CapturedOutput>
273where
274 R: Read + Send + 'static,
275{
276 thread::spawn(move || capture_output(&mut reader, cap, &stop))
277}
278
279fn capture_output<R>(reader: &mut R, cap: usize, stop: &AtomicBool) -> CapturedOutput
280where
281 R: Read,
282{
283 let mut bytes = Vec::with_capacity(cap.min(8192));
284 let mut buffer = [0_u8; 8192];
285 let mut truncated = false;
286 let mut shutdown_incomplete = false;
287 let mut shutdown_deadline = None;
288 loop {
289 if stop.load(Ordering::Acquire) {
290 shutdown_deadline.get_or_insert_with(|| Instant::now() + CAPTURE_SHUTDOWN_GRACE);
291 if shutdown_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
292 shutdown_incomplete = true;
293 break;
294 }
295 }
296
297 match reader.read(&mut buffer) {
298 Ok(0) => break,
299 Ok(read) => {
300 let remaining = cap.saturating_sub(bytes.len());
301 if remaining > 0 {
302 bytes.extend_from_slice(&buffer[..read.min(remaining)]);
303 }
304 if read > remaining {
305 truncated = true;
306 }
307 }
308 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
309 thread::sleep(Duration::from_millis(1));
310 }
311 Err(_) => break,
312 }
313 }
314 CapturedOutput {
315 bytes,
316 truncated: truncated || shutdown_incomplete,
317 }
318}
319
320#[cfg(unix)]
321fn set_nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
322 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
323 if flags == -1 {
324 return Err(io::Error::last_os_error());
325 }
326 let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
327 if result == -1 {
328 Err(io::Error::last_os_error())
329 } else {
330 Ok(())
331 }
332}
333
334fn bounded_output(
335 captured: &CapturedOutput,
336 output_cap: usize,
337 secret: Option<&str>,
338) -> (String, bool) {
339 let text = redact_secret(&String::from_utf8_lossy(&captured.bytes), secret);
340 let truncated =
341 captured.truncated || captured.bytes.len() > output_cap || text.len() > output_cap;
342 let mut end = text.len().min(output_cap);
343 while end > 0 && !text.is_char_boundary(end) {
344 end -= 1;
345 }
346 (text[..end].to_owned(), truncated)
347}
348
349fn join_capture(reader: Option<JoinHandle<CapturedOutput>>) -> CapturedOutput {
350 let Some(reader) = reader else {
351 return CapturedOutput {
352 bytes: Vec::new(),
353 truncated: false,
354 };
355 };
356
357 let deadline = Instant::now() + CAPTURE_SHUTDOWN_GRACE;
358 while !reader.is_finished() && Instant::now() < deadline {
359 thread::sleep(Duration::from_millis(1));
360 }
361 if reader.is_finished() {
362 reader.join().unwrap_or(CapturedOutput {
363 bytes: Vec::new(),
364 truncated: false,
365 })
366 } else {
367 CapturedOutput {
371 bytes: Vec::new(),
372 truncated: true,
373 }
374 }
375}
376
377fn kill_process_group(child_id: u32) {
378 #[cfg(unix)]
379 {
380 unsafe {
383 let _ = libc::kill(-(child_id as libc::pid_t), libc::SIGKILL);
384 }
385 }
386 #[cfg(not(unix))]
387 let _ = child_id;
388}
389
390pub fn redact_secret(text: &str, secret: Option<&str>) -> String {
391 crate::redaction::redact_secret(text, secret)
392}
393
394pub(crate) fn canceled_result(arguments: &str, secret: &str) -> CmdResult {
395 let command = serde_json::from_str::<Value>(arguments)
396 .ok()
397 .and_then(|value| {
398 value
399 .as_object()
400 .filter(|object| object.len() == 1)
401 .and_then(|object| object.get("command"))
402 .and_then(Value::as_str)
403 .map(str::to_owned)
404 })
405 .map(|command| redact_secret(&command, Some(secret)))
406 .unwrap_or_else(|| "{}".to_owned());
407 CmdResult::canceled(command, "command canceled before execution")
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::fs;
414 use std::sync::atomic::{AtomicU64, Ordering};
415 use std::time::{SystemTime, UNIX_EPOCH};
416
417 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
418
419 fn temporary_directory() -> std::path::PathBuf {
420 loop {
421 let stamp = SystemTime::now()
422 .duration_since(UNIX_EPOCH)
423 .expect("clock")
424 .as_nanos();
425 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
426 let path = std::env::temp_dir().join(format!(
427 "lucy-command-{stamp}-{}-{counter}",
428 std::process::id()
429 ));
430 match fs::create_dir(&path) {
431 Ok(()) => return path,
432 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
433 Err(error) => panic!("temp directory: {error}"),
434 }
435 }
436 }
437
438 #[test]
439 fn captures_nonzero_exit_and_both_streams() {
440 let cwd = temporary_directory();
441 let result = execute_command(
442 "printf out; printf err >&2; exit 7",
443 &cwd,
444 "LUCY_API_KEY",
445 None,
446 Duration::from_secs(2),
447 COMMAND_OUTPUT_CAP,
448 );
449 assert_eq!(result.exit_code, Some(7));
450 assert!(!result.timed_out);
451 assert_eq!(result.stdout, "out");
452 assert_eq!(result.stderr, "err");
453 fs::remove_dir_all(cwd).expect("remove temp directory");
454 }
455
456 #[test]
457 fn caps_streams_independently_and_marks_truncation() {
458 let cwd = temporary_directory();
459 let result = execute_command(
460 "printf 123456789; printf abcdefghij >&2",
461 &cwd,
462 "LUCY_API_KEY",
463 None,
464 Duration::from_secs(2),
465 4,
466 );
467 assert_eq!(result.stdout, "1234");
468 assert_eq!(result.stderr, "abcd");
469 assert!(result.stdout_truncated);
470 assert!(result.stderr_truncated);
471 fs::remove_dir_all(cwd).expect("remove temp directory");
472 }
473
474 #[cfg(unix)]
475 #[test]
476 fn bounds_lossy_invalid_utf8_output_and_marks_truncation() {
477 let cwd = temporary_directory();
478 let result = execute_command(
479 r"printf '\377\376\375\374'; printf '\377\376\375\374' >&2",
480 &cwd,
481 "LUCY_API_KEY",
482 None,
483 Duration::from_secs(2),
484 4,
485 );
486 assert!(result.stdout.len() <= 4);
487 assert!(result.stderr.len() <= 4);
488 assert!(result.stdout_truncated);
489 assert!(result.stderr_truncated);
490 fs::remove_dir_all(cwd).expect("remove temp directory");
491 }
492
493 #[test]
494 fn timeout_kills_the_command_group() {
495 let cwd = temporary_directory();
496 let result = execute_command(
497 "sleep 30",
498 &cwd,
499 "LUCY_API_KEY",
500 None,
501 Duration::from_millis(80),
502 COMMAND_OUTPUT_CAP,
503 );
504 assert!(result.timed_out);
505 assert!(result.exit_code.is_none() || result.exit_code != Some(0));
506 fs::remove_dir_all(cwd).expect("remove temp directory");
507 }
508
509 #[test]
510 fn cancellation_kills_a_running_command_group() {
511 let cwd = temporary_directory();
512 let token = CancellationToken::new();
513 let worker_token = token.clone();
514 let worker_cwd = cwd.clone();
515 let started = Instant::now();
516 let worker = thread::spawn(move || {
517 execute_command_with_cancellation(
518 "sleep 30",
519 &worker_cwd,
520 "LUCY_API_KEY",
521 None,
522 Duration::from_secs(30),
523 COMMAND_OUTPUT_CAP,
524 Some(&worker_token),
525 )
526 });
527 thread::sleep(Duration::from_millis(80));
528 token.cancel();
529 let result = worker.join().expect("command worker");
530 assert!(result.canceled);
531 assert_eq!(result.error.as_deref(), Some("command canceled"));
532 assert!(started.elapsed() < Duration::from_secs(2));
533 fs::remove_dir_all(cwd).expect("remove temp directory");
534 }
535
536 #[cfg(unix)]
537 #[test]
538 fn timeout_capture_returns_when_a_descendant_escapes_the_process_group() {
539 let python_available = Command::new("python3")
540 .arg("--version")
541 .stdout(Stdio::null())
542 .stderr(Stdio::null())
543 .status()
544 .map(|status| status.success())
545 .unwrap_or(false);
546 if !python_available {
547 return;
548 }
549
550 let cwd = temporary_directory();
551 let started = Instant::now();
552 let result = execute_command(
553 "python3 -c 'import os,time; os.setsid(); open(\"ready\",\"w\").close(); time.sleep(1)' & while [ ! -f ready ]; do sleep 0.01; done; sleep 2",
554 &cwd,
555 "LUCY_API_KEY",
556 None,
557 Duration::from_millis(300),
558 COMMAND_OUTPUT_CAP,
559 );
560 assert!(result.timed_out);
561 assert!(result.stdout_truncated);
562 assert!(
563 started.elapsed() < Duration::from_secs(1),
564 "capture cleanup exceeded the bounded grace period: {:?}",
565 started.elapsed()
566 );
567 fs::remove_dir_all(cwd).expect("remove temp directory");
568 }
569
570 #[test]
571 fn output_redacts_the_provider_key() {
572 let cwd = temporary_directory();
573 let result = execute_command(
574 "printf secret-key",
575 &cwd,
576 "LUCY_API_KEY",
577 Some("secret-key"),
578 Duration::from_secs(2),
579 COMMAND_OUTPUT_CAP,
580 );
581 assert!(!result.stdout.contains("secret-key"));
582 assert_eq!(result.stdout, "[REDACTED]");
583 fs::remove_dir_all(cwd).expect("remove temp directory");
584 }
585
586 #[test]
587 fn redaction_stays_within_the_capture_byte_bound() {
588 let cwd = temporary_directory();
589 let result = execute_command(
590 "printf x",
591 &cwd,
592 "LUCY_API_KEY",
593 Some("x"),
594 Duration::from_secs(2),
595 1,
596 );
597 assert_eq!(result.stdout.len(), 1);
598 assert!(!result.stdout.contains('x'));
599 fs::remove_dir_all(cwd).expect("remove temp directory");
600 }
601
602 #[test]
603 fn collision_markers_do_not_reintroduce_the_provider_key() {
604 let cwd = temporary_directory();
605 for secret in ["REDACTED", "[REDACTED]"] {
606 let command = format!("printf '{secret}'");
607 let result = execute_command(
608 &command,
609 &cwd,
610 "LUCY_API_KEY",
611 Some(secret),
612 Duration::from_secs(2),
613 COMMAND_OUTPUT_CAP,
614 );
615 assert!(!result.stdout.contains(secret));
616 assert!(!result.command.contains(secret));
617 }
618 fs::remove_dir_all(cwd).expect("remove temp directory");
619 }
620
621 #[test]
622 fn rejects_extra_command_arguments() {
623 let cwd = temporary_directory();
624 let result = execute(
625 r#"{"command":"pwd","extra":true}"#,
626 &cwd,
627 "LUCY_API_KEY",
628 None,
629 );
630 assert_eq!(
631 result.error.as_deref(),
632 Some("cmd arguments must contain only command")
633 );
634 fs::remove_dir_all(cwd).expect("remove temp directory");
635 }
636}