1use std::io::{self, Read, Seek, SeekFrom, Write};
2use std::process::{Command, Stdio};
3
4use tempfile::NamedTempFile;
5
6use crate::reducers::{RecoveryMarker, StreamReducer};
7use crate::rules::{PackRequest, Registry, SelectedRule, Selection};
8
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct ExpectedSelection {
11 pub pack_id: String,
12 pub rule_id: String,
13 pub source_digest: [u8; 32],
14}
15
16struct CapturedStream {
17 output: CapturedOutput,
18 raw: Option<RawSpool>,
19 archive_error: Option<String>,
20 raw_emitted: bool,
21}
22
23enum CapturedOutput {
24 Reduced(Box<StreamReducer>),
25 Passthrough,
26}
27
28struct RawSpool {
29 file: NamedTempFile,
30 bytes_written: usize,
31 #[cfg(test)]
32 fail_after: Option<usize>,
33}
34
35struct SpoolWriteError {
36 source: io::Error,
37 bytes_written: usize,
38}
39
40impl RawSpool {
41 fn new() -> io::Result<Self> {
42 Ok(Self {
43 file: NamedTempFile::new()?,
44 bytes_written: 0,
45 #[cfg(test)]
46 fail_after: None,
47 })
48 }
49
50 #[cfg(test)]
51 fn failing_after(limit: usize) -> io::Result<Self> {
52 let mut spool = Self::new()?;
53 spool.fail_after = Some(limit);
54 Ok(spool)
55 }
56
57 fn write_chunk(&mut self, body: &[u8]) -> Result<(), SpoolWriteError> {
58 #[cfg(test)]
59 let permitted = self.fail_after.map_or(body.len(), |limit| {
60 limit.saturating_sub(self.bytes_written).min(body.len())
61 });
62 #[cfg(not(test))]
63 let permitted = body.len();
64
65 let mut offset = 0;
66 while offset < permitted {
67 match self.file.write(&body[offset..permitted]) {
68 Ok(0) => {
69 return Err(SpoolWriteError {
70 source: io::Error::new(
71 io::ErrorKind::WriteZero,
72 "archive spool wrote zero bytes",
73 ),
74 bytes_written: offset,
75 });
76 }
77 Ok(count) => {
78 offset += count;
79 self.bytes_written = self.bytes_written.saturating_add(count);
80 }
81 Err(source) => {
82 return Err(SpoolWriteError {
83 source,
84 bytes_written: offset,
85 });
86 }
87 }
88 }
89
90 if permitted != body.len() {
91 return Err(SpoolWriteError {
92 source: io::Error::other("simulated archive spool failure"),
93 bytes_written: offset,
94 });
95 }
96 Ok(())
97 }
98
99 fn as_file_mut(&mut self) -> &mut std::fs::File {
100 self.file.as_file_mut()
101 }
102}
103
104pub fn run(
110 arguments: &[String],
111 archive_key: Option<&crate::archive::ArchiveKey>,
112) -> Result<i32, String> {
113 run_with_rules(arguments, archive_key, &[], None)
114}
115
116pub fn run_with_rules(
126 arguments: &[String],
127 archive_key: Option<&crate::archive::ArchiveKey>,
128 packs: &[PackRequest],
129 expected: Option<&ExpectedSelection>,
130) -> Result<i32, String> {
131 if arguments.is_empty() {
132 return Err("child command is missing".to_owned());
133 }
134 let selected = select_rule(arguments, packs, expected)?;
135 let passthrough = selected.is_none();
136
137 let (stdout_spool, stderr_spool) = if archive_key.is_some() {
138 (
139 Some(
140 RawSpool::new()
141 .map_err(|error| format!("could not create stdout archive spool: {error}"))?,
142 ),
143 Some(
144 RawSpool::new()
145 .map_err(|error| format!("could not create stderr archive spool: {error}"))?,
146 ),
147 )
148 } else {
149 (None, None)
150 };
151
152 let stdout_output = selected
153 .as_ref()
154 .map(|selected| StreamReducer::new(&selected.rule))
155 .transpose()?;
156 let stderr_output = selected
157 .as_ref()
158 .map(|selected| StreamReducer::new(&selected.rule))
159 .transpose()?;
160
161 let mut child = Command::new(&arguments[0])
162 .args(&arguments[1..])
163 .stdin(Stdio::inherit())
164 .stdout(Stdio::piped())
165 .stderr(Stdio::piped())
166 .spawn()
167 .map_err(|error| format!("could not start {}: {error}", arguments[0]))?;
168
169 let stdout = child
170 .stdout
171 .take()
172 .ok_or_else(|| "could not capture child stdout".to_owned())?;
173 let stderr = child
174 .stderr
175 .take()
176 .ok_or_else(|| "could not capture child stderr".to_owned())?;
177
178 let stdout_thread = std::thread::spawn(move || {
179 capture(
180 stdout,
181 stdout_spool,
182 io::stdout(),
183 stdout_output.map_or(CapturedOutput::Passthrough, |reducer| {
184 CapturedOutput::Reduced(Box::new(reducer))
185 }),
186 )
187 });
188 let stderr_thread = std::thread::spawn(move || {
189 capture(
190 stderr,
191 stderr_spool,
192 io::stderr(),
193 stderr_output.map_or(CapturedOutput::Passthrough, |reducer| {
194 CapturedOutput::Reduced(Box::new(reducer))
195 }),
196 )
197 });
198 let status = child
199 .wait()
200 .map_err(|error| format!("could not wait for child: {error}"))?;
201 finish_run(
202 status,
203 stdout_thread,
204 stderr_thread,
205 archive_key,
206 passthrough,
207 )
208}
209
210type CaptureThread = std::thread::JoinHandle<io::Result<CapturedStream>>;
211
212fn finish_run(
213 status: std::process::ExitStatus,
214 stdout_thread: CaptureThread,
215 stderr_thread: CaptureThread,
216 archive_key: Option<&crate::archive::ArchiveKey>,
217 passthrough: bool,
218) -> Result<i32, String> {
219 let mut stdout = join_capture(stdout_thread, "stdout")?;
220 let mut stderr = join_capture(stderr_thread, "stderr")?;
221 let succeeded = status.success();
222 let archive_ref = if passthrough {
223 None
224 } else if let Some(key) = archive_key {
225 match crate::archive::Archive::open_read_only().and_then(|archive| archive.archive_ref(key))
226 {
227 Ok(value) => Some(value),
228 Err(error) => {
229 restore_after_capture_error(&mut stdout, &mut stderr, passthrough)?;
230 eprintln!("yarp: archive failed after command execution: {error}");
231 return Ok(exit_code(status));
232 }
233 }
234 } else {
235 None
236 };
237 let stdout_after = finish_output(
238 std::mem::replace(&mut stdout.output, CapturedOutput::Passthrough),
239 succeeded,
240 archive_ref.as_deref().map(|archive_ref| RecoveryMarker {
241 archive_ref,
242 source: "stdout",
243 completeness: "complete",
244 }),
245 );
246 let stderr_after = finish_output(
247 std::mem::replace(&mut stderr.output, CapturedOutput::Passthrough),
248 succeeded,
249 archive_ref.as_deref().map(|archive_ref| RecoveryMarker {
250 archive_ref,
251 source: "stderr",
252 completeness: "complete",
253 }),
254 );
255 let capture_errors = capture_errors(&stdout, &stderr);
256 if !capture_errors.is_empty() {
257 restore_after_capture_error(&mut stdout, &mut stderr, passthrough)?;
258 eprintln!(
259 "yarp: archive failed after command execution: {}",
260 capture_errors.join("; ")
261 );
262 return Ok(exit_code(status));
263 }
264 if let Some(key) = archive_key
265 && let Err(error) = archive_captures(
266 key,
267 &mut stdout,
268 &mut stderr,
269 stdout_after.as_deref(),
270 stderr_after.as_deref(),
271 passthrough,
272 )
273 {
274 if !passthrough {
275 emit_raw(&mut stdout, &mut io::stdout(), "stdout")?;
276 emit_raw(&mut stderr, &mut io::stderr(), "stderr")?;
277 }
278 eprintln!("yarp: archive failed after command execution: {error}");
279 return Ok(exit_code(status));
280 }
281 if let Some(stdout_after) = stdout_after {
282 io::stdout()
283 .write_all(&stdout_after)
284 .map_err(|error| format!("could not write stdout: {error}"))?;
285 }
286 if let Some(stderr_after) = stderr_after {
287 io::stderr()
288 .write_all(&stderr_after)
289 .map_err(|error| format!("could not write stderr: {error}"))?;
290 }
291 Ok(exit_code(status))
292}
293
294fn capture_errors(stdout: &CapturedStream, stderr: &CapturedStream) -> Vec<String> {
295 [
296 stdout
297 .archive_error
298 .as_ref()
299 .map(|error| format!("stdout: {error}")),
300 stderr
301 .archive_error
302 .as_ref()
303 .map(|error| format!("stderr: {error}")),
304 ]
305 .into_iter()
306 .flatten()
307 .collect()
308}
309
310fn restore_after_capture_error(
311 stdout: &mut CapturedStream,
312 stderr: &mut CapturedStream,
313 passthrough: bool,
314) -> Result<(), String> {
315 if !passthrough && !stdout.raw_emitted {
316 emit_raw(stdout, &mut io::stdout(), "stdout")?;
317 }
318 if !passthrough && !stderr.raw_emitted {
319 emit_raw(stderr, &mut io::stderr(), "stderr")?;
320 }
321 Ok(())
322}
323
324fn archive_captures(
325 key: &crate::archive::ArchiveKey,
326 stdout: &mut CapturedStream,
327 stderr: &mut CapturedStream,
328 stdout_after: Option<&[u8]>,
329 stderr_after: Option<&[u8]>,
330 passthrough: bool,
331) -> Result<(), String> {
332 let stdout_raw = stdout
333 .raw
334 .as_mut()
335 .ok_or_else(|| "stdout archive spool is missing".to_owned())?;
336 let stderr_raw = stderr
337 .raw
338 .as_mut()
339 .ok_or_else(|| "stderr archive spool is missing".to_owned())?;
340 let mut archive = crate::archive::Archive::open()?;
341 if passthrough {
342 archive.capture_passthrough_streams(
343 key,
344 unix_time_ms(),
345 stdout_raw.as_file_mut(),
346 stderr_raw.as_file_mut(),
347 )
348 } else {
349 archive.capture_streams(
350 key,
351 unix_time_ms(),
352 stdout_raw.as_file_mut(),
353 stderr_raw.as_file_mut(),
354 stdout_after.unwrap_or_default(),
355 stderr_after.unwrap_or_default(),
356 )
357 }
358}
359
360fn select_rule(
361 arguments: &[String],
362 packs: &[PackRequest],
363 expected: Option<&ExpectedSelection>,
364) -> Result<Option<SelectedRule>, String> {
365 let mut registry = match Registry::load(packs) {
366 Ok(registry) => registry,
367 Err(_) if expected.is_some() => return Ok(None),
368 Err(error) => return Err(error),
369 };
370 let selection = match registry.select(arguments) {
371 Ok(selection) => selection,
372 Err(_) if expected.is_some() => return Ok(None),
373 Err(error) => return Err(error),
374 };
375 match (selection, expected) {
376 (Selection::Reduce(selected), Some(expected))
377 if selected.pack_id == expected.pack_id
378 && selected.rule.id == expected.rule_id
379 && selected.source_digest == expected.source_digest =>
380 {
381 Ok(Some(selected))
382 }
383 (Selection::Reduce(selected), None) => Ok(Some(selected)),
384 (_, Some(_)) => Ok(None),
385 (Selection::Unsupported, None) => Err("command is not on the YARP allowlist".to_owned()),
386 (Selection::Transform(_), None) => {
387 Err("pipeline transforms cannot run as standalone YARP rules".to_owned())
388 }
389 (Selection::Passthrough(_), None) => {
390 Err("command is protected by a YARP pass-through rule".to_owned())
391 }
392 (Selection::Ambiguous(ids), None) => Err(format!(
393 "command matches multiple YARP reduction rules: {}",
394 ids.join(", ")
395 )),
396 }
397}
398
399fn capture(
400 mut reader: impl Read,
401 mut raw: Option<RawSpool>,
402 mut fallback: impl Write,
403 mut output: CapturedOutput,
404) -> io::Result<CapturedStream> {
405 let passthrough = matches!(output, CapturedOutput::Passthrough);
406 let mut archive_error = None;
407 let mut raw_emitted = passthrough;
408 let mut buffer = [0_u8; 8 * 1024];
409
410 loop {
411 let count = reader.read(&mut buffer)?;
412 if count == 0 {
413 break;
414 }
415 let chunk = &buffer[..count];
416 if passthrough {
417 fallback.write_all(chunk)?;
418 }
419 if !passthrough && raw_emitted {
420 fallback.write_all(chunk)?;
421 } else if let Some(spool) = &mut raw
422 && let Err(error) = spool.write_chunk(chunk)
423 {
424 if !passthrough {
425 rewind_and_copy(spool.as_file_mut(), &mut fallback)?;
426 fallback.write_all(&chunk[error.bytes_written..])?;
427 }
428 archive_error = Some(format!("could not write archive spool: {}", error.source));
429 raw = None;
430 raw_emitted = true;
431 }
432 if let CapturedOutput::Reduced(reducer) = &mut output {
433 reducer.push(chunk);
434 }
435 }
436
437 if let Some(spool) = &mut raw
438 && let Err(error) = spool.file.flush()
439 {
440 if !passthrough {
441 rewind_and_copy(spool.as_file_mut(), &mut fallback)?;
442 }
443 archive_error = Some(format!("could not flush archive spool: {error}"));
444 raw = None;
445 raw_emitted = true;
446 }
447 if raw_emitted {
448 fallback.flush()?;
449 }
450 Ok(CapturedStream {
451 output,
452 raw,
453 archive_error,
454 raw_emitted,
455 })
456}
457
458fn finish_output(
459 output: CapturedOutput,
460 success: bool,
461 recovery: Option<RecoveryMarker<'_>>,
462) -> Option<Vec<u8>> {
463 match output {
464 CapturedOutput::Reduced(reducer) => Some((*reducer).finish(success, recovery)),
465 CapturedOutput::Passthrough => None,
466 }
467}
468
469fn join_capture(
470 thread: std::thread::JoinHandle<io::Result<CapturedStream>>,
471 stream: &str,
472) -> Result<CapturedStream, String> {
473 thread
474 .join()
475 .map_err(|_| format!("{stream} capture thread panicked"))?
476 .map_err(|error| format!("could not read child {stream}: {error}"))
477}
478
479fn rewind_and_copy(file: &mut std::fs::File, output: &mut impl Write) -> io::Result<()> {
480 file.seek(SeekFrom::Start(0))?;
481 io::copy(file, output)?;
482 Ok(())
483}
484
485fn copy_raw(file: &mut std::fs::File, output: &mut impl Write, stream: &str) -> Result<(), String> {
486 rewind_and_copy(file, output)
487 .map_err(|error| format!("could not restore raw {stream}: {error}"))
488}
489
490fn emit_raw(
491 captured: &mut CapturedStream,
492 output: &mut impl Write,
493 stream: &str,
494) -> Result<(), String> {
495 let spool = captured
496 .raw
497 .as_mut()
498 .ok_or_else(|| format!("raw {stream} archive spool is missing"))?;
499 copy_raw(spool.as_file_mut(), output, stream)
500}
501
502fn unix_time_ms() -> i64 {
503 std::time::SystemTime::now()
504 .duration_since(std::time::UNIX_EPOCH)
505 .map_or(0, |duration| {
506 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
507 })
508}
509
510fn exit_code(status: std::process::ExitStatus) -> i32 {
511 if let Some(code) = status.code() {
512 return code;
513 }
514
515 #[cfg(unix)]
516 {
517 use std::os::unix::process::ExitStatusExt;
518 status.signal().map_or(1, |signal| 128 + signal)
519 }
520
521 #[cfg(not(unix))]
522 {
523 1
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use std::io::Cursor;
531
532 fn selected_rule(arguments: &[&str]) -> SelectedRule {
533 let mut registry = Registry::builtins_only();
534 let arguments = arguments
535 .iter()
536 .map(ToString::to_string)
537 .collect::<Vec<_>>();
538 let Selection::Reduce(selected) = registry.select(&arguments).expect("selection") else {
539 panic!("expected reduction rule");
540 };
541 selected
542 }
543
544 #[test]
545 fn capture_reduces_with_the_selected_rule() {
546 let selected = selected_rule(&["cargo", "test"]);
547 let input = " Compiling crate\n".repeat(400);
548 let reducer = StreamReducer::new(&selected.rule).expect("reducer");
549 let captured = capture(
550 Cursor::new(input.as_bytes()),
551 None,
552 io::sink(),
553 CapturedOutput::Reduced(Box::new(reducer)),
554 )
555 .expect("capture");
556 let output = finish_output(captured.output, true, None).expect("reduced output");
557 assert!(output.len() < input.len());
558 }
559
560 #[test]
561 fn passthrough_capture_emits_exact_bytes() {
562 let input = b"exact\xffbytes\n";
563 let mut output = Vec::new();
564 let captured = capture(
565 Cursor::new(input),
566 None,
567 &mut output,
568 CapturedOutput::Passthrough,
569 )
570 .expect("capture");
571 assert_eq!(output, input);
572 assert!(finish_output(captured.output, true, None).is_none());
573 }
574
575 #[test]
576 fn archive_spool_failure_emits_the_exact_raw_stream() {
577 let input = b"raw output that exceeds the simulated limit\n";
578 let selected = selected_rule(&["git", "status"]);
579 let spool = RawSpool::failing_after(7).expect("spool");
580 let mut output = Vec::new();
581 let captured = capture(
582 Cursor::new(input),
583 Some(spool),
584 &mut output,
585 CapturedOutput::Reduced(Box::new(
586 StreamReducer::new(&selected.rule).expect("reducer"),
587 )),
588 )
589 .expect("capture");
590 assert_eq!(output, input);
591 assert!(captured.raw_emitted);
592 assert!(captured.raw.is_none());
593 assert!(
594 captured
595 .archive_error
596 .is_some_and(|error| error.contains("archive spool"))
597 );
598 }
599
600 #[test]
601 fn rejects_unselected_direct_children() {
602 let result = run(&["cat".to_owned(), ".env".to_owned()], None);
603 assert_eq!(
604 result,
605 Err("pipeline transforms cannot run as standalone YARP rules".to_owned())
606 );
607 let result = run(&["echo".to_owned(), "secret".to_owned()], None);
608 assert_eq!(
609 result,
610 Err("command is not on the YARP allowlist".to_owned())
611 );
612 }
613
614 #[test]
615 fn rewrite_disagreement_fails_open() {
616 let expected = ExpectedSelection {
617 pack_id: "yarp-builtins".to_owned(),
618 rule_id: "git/status".to_owned(),
619 source_digest: [0_u8; 32],
620 };
621 let selected = select_rule(
622 &["git".to_owned(), "status".to_owned()],
623 &[],
624 Some(&expected),
625 )
626 .expect("selection");
627 assert!(selected.is_none());
628 }
629}