1use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::sync::LazyLock;
8
9use regex_lite::Regex;
10use tokio_util::sync::CancellationToken;
11
12use crate::tools::bash::{Event, create_log, exit_code_of, run_child, shell_command, shell_with};
13use crate::tools::truncate::{DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncate_tail};
14
15pub use yapi_types::rpc::BashResult;
16
17static ANSI: LazyLock<Option<Regex>> = LazyLock::new(|| {
18 Regex::new(concat!(
20 r"(?:\x1b\][\s\S]*?(?:\x07|\x1b\\|\x{9c}))",
21 r"|[\x1b\x{9b}][\[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]",
22 ))
23 .ok()
24});
25
26pub fn strip_ansi(text: &str) -> String {
29 if !text.contains(['\x1b', '\u{9b}']) {
30 return text.to_owned();
31 }
32 match ANSI.as_ref() {
33 Some(regex) => regex.replace_all(text, "").into_owned(),
34 None => text.to_owned(),
35 }
36}
37
38pub fn sanitize_binary(text: &str) -> String {
41 text.chars()
42 .filter(|c| {
43 !matches!(c, '\x00'..='\x08' | '\x0b' | '\x0c' | '\x0e'..='\x1f' | '\u{fff9}'..='\u{fffb}')
44 })
45 .collect()
46}
47
48#[derive(Default)]
49struct Output {
50 chunks: Vec<String>,
51 kept: usize,
52 total_bytes: usize,
53 pending: Vec<u8>,
54 file: Option<(PathBuf, std::fs::File)>,
55}
56
57impl Output {
58 fn ensure_file(&mut self) {
59 if self.file.is_some() {
60 return;
61 }
62 if let Some((path, mut file)) = create_log() {
63 for chunk in &self.chunks {
64 let _ = file.write_all(chunk.as_bytes());
65 }
66 self.file = Some((path, file));
67 }
68 }
69
70 fn push(&mut self, data: &[u8], on_chunk: &mut impl FnMut(&str)) {
71 self.total_bytes += data.len();
72 let decoded = yapi_types::js::decode_utf8_stream(&mut self.pending, data);
73 self.text(&decoded, on_chunk);
74 }
75
76 fn text(&mut self, decoded: &str, on_chunk: &mut impl FnMut(&str)) {
77 let text = sanitize_binary(&strip_ansi(decoded)).replace('\r', "");
78 if self.total_bytes > DEFAULT_MAX_BYTES {
79 self.ensure_file();
80 }
81 if let Some((_, file)) = &mut self.file {
82 let _ = file.write_all(text.as_bytes());
83 }
84 self.kept += text.encode_utf16().count();
85 self.chunks.push(text.clone());
86 while self.kept > DEFAULT_MAX_BYTES * 2 && self.chunks.len() > 1 {
87 let removed = self.chunks.remove(0);
88 self.kept -= removed.encode_utf16().count();
89 }
90 on_chunk(&text);
91 }
92
93 fn finish(mut self, exit_code: Option<i32>, cancelled: bool) -> BashResult {
94 let full = self.chunks.concat();
95 let truncation = truncate_tail(&full, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
96 if truncation.truncated {
97 self.ensure_file();
98 }
99 BashResult {
100 output: if truncation.truncated {
101 truncation.content
102 } else {
103 full
104 },
105 exit_code: if cancelled { None } else { exit_code },
106 cancelled,
107 truncated: truncation.truncated,
108 full_output_path: self.file.map(|(path, _)| path.display().to_string()),
109 }
110 }
111}
112
113pub async fn execute(
116 command: &str,
117 cwd: &Path,
118 shell_path: Option<&str>,
119 bin_dir: &Path,
120 cancel: CancellationToken,
121 mut on_chunk: impl FnMut(&str),
122) -> Result<BashResult, String> {
123 if cancel.is_cancelled() {
124 return Err("aborted".into());
125 }
126 let shell = shell_with(shell_path)?;
127 if !cwd.exists() {
128 return Err(format!(
129 "Working directory does not exist: {}\nCannot execute bash commands.",
130 cwd.display()
131 ));
132 }
133 let child = shell_command(shell, command, cwd, bin_dir)
134 .spawn()
135 .map_err(|err| err.to_string())?;
136 let mut output = Output::default();
137 let (status, stop) = run_child(child, &cancel, None, |event| {
138 if let Event::Output(bytes) = event {
139 output.push(bytes, &mut on_chunk);
140 }
141 None
142 })
143 .await;
144 let exit_code = status.map(|status| exit_code_of(status).unwrap_or(1));
145 Ok(output.finish(exit_code, stop.is_some()))
148}
149
150pub async fn execute_with<F>(
155 exec: impl FnOnce(tokio::sync::mpsc::UnboundedSender<Vec<u8>>) -> F,
156 cancel: &CancellationToken,
157 mut on_chunk: impl FnMut(&str),
158) -> Result<BashResult, String>
159where
160 F: std::future::Future<Output = Result<Option<i32>, String>>,
161{
162 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
163 let mut output = Output::default();
164 let run = exec(sender);
165 tokio::pin!(run);
166 let outcome = loop {
167 tokio::select! {
168 biased;
169 Some(bytes) = receiver.recv() => output.push(&bytes, &mut on_chunk),
170 outcome = &mut run => break outcome,
171 }
172 };
173 while let Ok(bytes) = receiver.try_recv() {
174 output.push(&bytes, &mut on_chunk);
175 }
176 let cancelled = cancel.is_cancelled();
177 match outcome {
178 Ok(exit_code) => Ok(output.finish(exit_code, cancelled)),
179 Err(_) if cancelled => Ok(output.finish(None, true)),
180 Err(error) => Err(error),
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn strips_escapes_and_controls() {
190 assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
191 assert_eq!(strip_ansi("\x1b]8;;http://x\x07link\x1b]8;;\x07"), "link");
192 assert_eq!(sanitize_binary("a\x00b\tc\x07"), "ab\tc");
193 }
194
195 #[cfg(unix)]
196 #[tokio::test]
197 async fn runs_and_cancels() {
198 let dir = std::env::temp_dir();
199 let mut seen = String::new();
200 let result = execute(
202 "printf 'one\\r\\ntwo\\n'; exit 3",
203 &dir,
204 Some(""),
205 &dir,
206 CancellationToken::new(),
207 |chunk| seen.push_str(chunk),
208 )
209 .await
210 .unwrap_or_default();
211 assert_eq!(result.output, "one\ntwo\n");
212 assert_eq!(result.exit_code, Some(3));
213 assert_eq!(seen, "one\ntwo\n");
214
215 let cancel = CancellationToken::new();
216 let trigger = cancel.clone();
217 tokio::spawn(async move {
218 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
219 trigger.cancel();
220 });
221 let result = execute("sleep 5", &dir, None, &dir, cancel, |_| {})
222 .await
223 .unwrap_or_default();
224 assert!(result.cancelled);
225 assert_eq!(result.exit_code, None);
226 }
227
228 #[cfg(unix)]
229 #[tokio::test]
230 async fn drops_trailing_partial_char() {
231 let dir = std::env::temp_dir();
232 let mut seen = String::new();
233 let result = execute(
235 "printf 'ok \\342\\202'",
236 &dir,
237 None,
238 &dir,
239 CancellationToken::new(),
240 |chunk| seen.push_str(chunk),
241 )
242 .await
243 .unwrap_or_default();
244 assert_eq!(result.output, "ok ");
245 assert_eq!(seen, "ok ");
246 }
247}