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 fn prepared_path(&mut self) -> Result<std::path::PathBuf, String> {
104 self.file
105 .flush()
106 .map_err(|error| format!("could not flush archive spool: {error}"))?;
107 Ok(self.file.path().to_path_buf())
108 }
109}
110
111pub fn run(
117 arguments: &[String],
118 archive_key: Option<&crate::archive::ArchiveKey>,
119) -> Result<i32, String> {
120 run_with_rules(arguments, archive_key, &[], None)
121}
122
123pub fn run_with_rules(
133 arguments: &[String],
134 archive_key: Option<&crate::archive::ArchiveKey>,
135 packs: &[PackRequest],
136 expected: Option<&ExpectedSelection>,
137) -> Result<i32, String> {
138 if arguments.is_empty() {
139 return Err("child command is missing".to_owned());
140 }
141 let selected = select_rule(arguments, packs, expected)?;
142 let passthrough = selected.is_none();
143
144 let (stdout_spool, stderr_spool) = if archive_key.is_some() {
145 (
146 Some(
147 RawSpool::new()
148 .map_err(|error| format!("could not create stdout archive spool: {error}"))?,
149 ),
150 Some(
151 RawSpool::new()
152 .map_err(|error| format!("could not create stderr archive spool: {error}"))?,
153 ),
154 )
155 } else {
156 (None, None)
157 };
158
159 let stdout_output = selected
160 .as_ref()
161 .map(|selected| StreamReducer::new(&selected.rule))
162 .transpose()?;
163 let stderr_output = selected
164 .as_ref()
165 .map(|selected| StreamReducer::new(&selected.rule))
166 .transpose()?;
167
168 let mut child = Command::new(&arguments[0])
169 .args(&arguments[1..])
170 .stdin(Stdio::inherit())
171 .stdout(Stdio::piped())
172 .stderr(Stdio::piped())
173 .spawn()
174 .map_err(|error| format!("could not start {}: {error}", arguments[0]))?;
175
176 let stdout = child
177 .stdout
178 .take()
179 .ok_or_else(|| "could not capture child stdout".to_owned())?;
180 let stderr = child
181 .stderr
182 .take()
183 .ok_or_else(|| "could not capture child stderr".to_owned())?;
184
185 let stdout_thread = std::thread::spawn(move || {
186 capture(
187 stdout,
188 stdout_spool,
189 io::stdout(),
190 stdout_output.map_or(CapturedOutput::Passthrough, |reducer| {
191 CapturedOutput::Reduced(Box::new(reducer))
192 }),
193 )
194 });
195 let stderr_thread = std::thread::spawn(move || {
196 capture(
197 stderr,
198 stderr_spool,
199 io::stderr(),
200 stderr_output.map_or(CapturedOutput::Passthrough, |reducer| {
201 CapturedOutput::Reduced(Box::new(reducer))
202 }),
203 )
204 });
205 let status = child
206 .wait()
207 .map_err(|error| format!("could not wait for child: {error}"))?;
208 finish_run(
209 status,
210 stdout_thread,
211 stderr_thread,
212 archive_key,
213 passthrough,
214 )
215}
216
217type CaptureThread = std::thread::JoinHandle<io::Result<CapturedStream>>;
218
219fn finish_run(
220 status: std::process::ExitStatus,
221 stdout_thread: CaptureThread,
222 stderr_thread: CaptureThread,
223 archive_key: Option<&crate::archive::ArchiveKey>,
224 passthrough: bool,
225) -> Result<i32, String> {
226 let mut stdout = join_capture(stdout_thread, "stdout")?;
227 let mut stderr = join_capture(stderr_thread, "stderr")?;
228 let succeeded = status.success();
229 let archive_ref = if passthrough {
230 None
231 } else if let Some(key) = archive_key {
232 match crate::archive::Archive::open_read_only().and_then(|archive| archive.archive_ref(key))
233 {
234 Ok(value) => Some(value),
235 Err(error) => {
236 restore_after_capture_error(&mut stdout, &mut stderr)?;
237 eprintln!("yarp: archive failed after command execution: {error}");
238 return Ok(exit_code(status));
239 }
240 }
241 } else {
242 None
243 };
244 let stdout_after = finish_output(
245 std::mem::replace(&mut stdout.output, CapturedOutput::Passthrough),
246 succeeded,
247 archive_ref.as_deref().map(|archive_ref| RecoveryMarker {
248 archive_ref,
249 source: "stdout",
250 completeness: "complete",
251 }),
252 );
253 let stderr_after = finish_output(
254 std::mem::replace(&mut stderr.output, CapturedOutput::Passthrough),
255 succeeded,
256 archive_ref.as_deref().map(|archive_ref| RecoveryMarker {
257 archive_ref,
258 source: "stderr",
259 completeness: "complete",
260 }),
261 );
262 let capture_errors = capture_errors(&stdout, &stderr);
263 if !capture_errors.is_empty() {
264 restore_after_capture_error(&mut stdout, &mut stderr)?;
265 eprintln!(
266 "yarp: archive failed after command execution: {}",
267 capture_errors.join("; ")
268 );
269 return Ok(exit_code(status));
270 }
271 if let Some(key) = archive_key
272 && let Err(error) = archive_captures(
273 key,
274 &mut stdout,
275 &mut stderr,
276 stdout_after.as_deref(),
277 stderr_after.as_deref(),
278 passthrough,
279 )
280 {
281 restore_after_capture_error(&mut stdout, &mut stderr)?;
282 eprintln!("yarp: archive failed after command execution: {error}");
283 return Ok(exit_code(status));
284 }
285 if passthrough {
286 restore_after_capture_error(&mut stdout, &mut stderr)?;
287 }
288 if let Some(stdout_after) = stdout_after {
289 io::stdout()
290 .write_all(&stdout_after)
291 .map_err(|error| format!("could not write stdout: {error}"))?;
292 }
293 if let Some(stderr_after) = stderr_after {
294 io::stderr()
295 .write_all(&stderr_after)
296 .map_err(|error| format!("could not write stderr: {error}"))?;
297 }
298 Ok(exit_code(status))
299}
300
301fn capture_errors(stdout: &CapturedStream, stderr: &CapturedStream) -> Vec<String> {
302 [
303 stdout
304 .archive_error
305 .as_ref()
306 .map(|error| format!("stdout: {error}")),
307 stderr
308 .archive_error
309 .as_ref()
310 .map(|error| format!("stderr: {error}")),
311 ]
312 .into_iter()
313 .flatten()
314 .collect()
315}
316
317fn restore_after_capture_error(
318 stdout: &mut CapturedStream,
319 stderr: &mut CapturedStream,
320) -> Result<(), String> {
321 if !stdout.raw_emitted {
322 emit_raw(stdout, &mut io::stdout(), "stdout")?;
323 stdout.raw_emitted = true;
324 }
325 if !stderr.raw_emitted {
326 emit_raw(stderr, &mut io::stderr(), "stderr")?;
327 stderr.raw_emitted = true;
328 }
329 Ok(())
330}
331
332fn archive_captures(
333 key: &crate::archive::ArchiveKey,
334 stdout: &mut CapturedStream,
335 stderr: &mut CapturedStream,
336 stdout_after: Option<&[u8]>,
337 stderr_after: Option<&[u8]>,
338 passthrough: bool,
339) -> Result<(), String> {
340 let stdout_path = stdout
341 .raw
342 .as_mut()
343 .ok_or_else(|| "stdout archive spool is missing".to_owned())?
344 .prepared_path()?;
345 let stderr_path = stderr
346 .raw
347 .as_mut()
348 .ok_or_else(|| "stderr archive spool is missing".to_owned())?
349 .prepared_path()?;
350 let captured_at_ms = unix_time_ms();
351 let operation = if passthrough {
352 crate::archive_protocol::ArchiveOperation::CapturePassthroughStreams {
353 request_id: 1,
354 schema_version: crate::archive_protocol::INGEST_SCHEMA_VERSION,
355 session: key.session.clone(),
356 source_call_id: key.source_call_id.clone(),
357 captured_at_ms,
358 stdout_path,
359 stderr_path,
360 }
361 } else {
362 let stdout_after = spool_bytes(stdout_after.unwrap_or_default(), "stdout")?;
363 let stderr_after = spool_bytes(stderr_after.unwrap_or_default(), "stderr")?;
364 let operation = crate::archive_protocol::ArchiveOperation::CaptureStreams {
365 request_id: 1,
366 schema_version: crate::archive_protocol::INGEST_SCHEMA_VERSION,
367 session: key.session.clone(),
368 source_call_id: key.source_call_id.clone(),
369 captured_at_ms,
370 stdout_before_path: stdout_path,
371 stderr_before_path: stderr_path,
372 stdout_after_path: stdout_after.path().to_path_buf(),
373 stderr_after_path: stderr_after.path().to_path_buf(),
374 };
375 return crate::archive_client::execute(operation).map(|_| ());
376 };
377 crate::archive_client::execute(operation).map(|_| ())
378}
379
380fn spool_bytes(body: &[u8], label: &str) -> Result<NamedTempFile, String> {
381 let mut file = NamedTempFile::new()
382 .map_err(|error| format!("could not create {label} archive spool: {error}"))?;
383 file.write_all(body)
384 .and_then(|()| file.flush())
385 .map_err(|error| format!("could not write {label} archive spool: {error}"))?;
386 Ok(file)
387}
388
389fn select_rule(
390 arguments: &[String],
391 packs: &[PackRequest],
392 expected: Option<&ExpectedSelection>,
393) -> Result<Option<SelectedRule>, String> {
394 let mut registry = match Registry::load(packs) {
395 Ok(registry) => registry,
396 Err(_) if expected.is_some() => return Ok(None),
397 Err(error) => return Err(error),
398 };
399 let selection = match registry.select(arguments) {
400 Ok(selection) => selection,
401 Err(_) if expected.is_some() => return Ok(None),
402 Err(error) => return Err(error),
403 };
404 match (selection, expected) {
405 (Selection::Reduce(selected), Some(expected))
406 if selected.pack_id == expected.pack_id
407 && selected.rule.id == expected.rule_id
408 && selected.source_digest == expected.source_digest =>
409 {
410 Ok(Some(selected))
411 }
412 (Selection::Reduce(selected), None) => Ok(Some(selected)),
413 (_, Some(_)) => Ok(None),
414 (Selection::Unsupported, None) => Err("command is not on the YARP allowlist".to_owned()),
415 (Selection::Transform(_), None) => {
416 Err("pipeline transforms cannot run as standalone YARP rules".to_owned())
417 }
418 (Selection::Passthrough(_), None) => {
419 Err("command is protected by a YARP pass-through rule".to_owned())
420 }
421 (Selection::Ambiguous(ids), None) => Err(format!(
422 "command matches multiple YARP reduction rules: {}",
423 ids.join(", ")
424 )),
425 }
426}
427
428fn capture(
429 mut reader: impl Read,
430 mut raw: Option<RawSpool>,
431 mut fallback: impl Write,
432 mut output: CapturedOutput,
433) -> io::Result<CapturedStream> {
434 let passthrough = matches!(output, CapturedOutput::Passthrough);
435 let mut archive_error = None;
436 let mut raw_emitted = passthrough && raw.is_none();
437 let mut buffer = [0_u8; 8 * 1024];
438
439 loop {
440 let count = reader.read(&mut buffer)?;
441 if count == 0 {
442 break;
443 }
444 let chunk = &buffer[..count];
445 if passthrough && raw_emitted {
446 fallback.write_all(chunk)?;
447 }
448 if !passthrough && raw_emitted {
449 fallback.write_all(chunk)?;
450 } else if let Some(spool) = &mut raw
451 && let Err(error) = spool.write_chunk(chunk)
452 {
453 if !raw_emitted {
454 rewind_and_copy(spool.as_file_mut(), &mut fallback)?;
455 fallback.write_all(&chunk[error.bytes_written..])?;
456 }
457 archive_error = Some(format!("could not write archive spool: {}", error.source));
458 raw = None;
459 raw_emitted = true;
460 }
461 if let CapturedOutput::Reduced(reducer) = &mut output {
462 reducer.push(chunk);
463 }
464 }
465
466 if let Some(spool) = &mut raw
467 && let Err(error) = spool.file.flush()
468 {
469 if !raw_emitted {
470 rewind_and_copy(spool.as_file_mut(), &mut fallback)?;
471 }
472 archive_error = Some(format!("could not flush archive spool: {error}"));
473 raw = None;
474 raw_emitted = true;
475 }
476 if raw_emitted {
477 fallback.flush()?;
478 }
479 Ok(CapturedStream {
480 output,
481 raw,
482 archive_error,
483 raw_emitted,
484 })
485}
486
487fn finish_output(
488 output: CapturedOutput,
489 success: bool,
490 recovery: Option<RecoveryMarker<'_>>,
491) -> Option<Vec<u8>> {
492 match output {
493 CapturedOutput::Reduced(reducer) => Some((*reducer).finish(success, recovery)),
494 CapturedOutput::Passthrough => None,
495 }
496}
497
498fn join_capture(
499 thread: std::thread::JoinHandle<io::Result<CapturedStream>>,
500 stream: &str,
501) -> Result<CapturedStream, String> {
502 thread
503 .join()
504 .map_err(|_| format!("{stream} capture thread panicked"))?
505 .map_err(|error| format!("could not read child {stream}: {error}"))
506}
507
508fn rewind_and_copy(file: &mut std::fs::File, output: &mut impl Write) -> io::Result<()> {
509 file.seek(SeekFrom::Start(0))?;
510 io::copy(file, output)?;
511 Ok(())
512}
513
514fn copy_raw(file: &mut std::fs::File, output: &mut impl Write, stream: &str) -> Result<(), String> {
515 rewind_and_copy(file, output)
516 .map_err(|error| format!("could not restore raw {stream}: {error}"))
517}
518
519fn emit_raw(
520 captured: &mut CapturedStream,
521 output: &mut impl Write,
522 stream: &str,
523) -> Result<(), String> {
524 let spool = captured
525 .raw
526 .as_mut()
527 .ok_or_else(|| format!("raw {stream} archive spool is missing"))?;
528 copy_raw(spool.as_file_mut(), output, stream)?;
529 output
530 .flush()
531 .map_err(|error| format!("could not flush raw {stream}: {error}"))
532}
533
534fn unix_time_ms() -> i64 {
535 std::time::SystemTime::now()
536 .duration_since(std::time::UNIX_EPOCH)
537 .map_or(0, |duration| {
538 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
539 })
540}
541
542fn exit_code(status: std::process::ExitStatus) -> i32 {
543 if let Some(code) = status.code() {
544 return code;
545 }
546
547 #[cfg(unix)]
548 {
549 use std::os::unix::process::ExitStatusExt;
550 status.signal().map_or(1, |signal| 128 + signal)
551 }
552
553 #[cfg(not(unix))]
554 {
555 1
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use std::io::Cursor;
563
564 fn selected_rule(arguments: &[&str]) -> SelectedRule {
565 let mut registry = Registry::builtins_only();
566 let arguments = arguments
567 .iter()
568 .map(ToString::to_string)
569 .collect::<Vec<_>>();
570 let Selection::Reduce(selected) = registry.select(&arguments).expect("selection") else {
571 panic!("expected reduction rule");
572 };
573 selected
574 }
575
576 #[test]
577 fn capture_reduces_with_the_selected_rule() {
578 let selected = selected_rule(&["cargo", "test"]);
579 let input = " Compiling crate\n".repeat(400);
580 let reducer = StreamReducer::new(&selected.rule).expect("reducer");
581 let captured = capture(
582 Cursor::new(input.as_bytes()),
583 None,
584 io::sink(),
585 CapturedOutput::Reduced(Box::new(reducer)),
586 )
587 .expect("capture");
588 let output = finish_output(captured.output, true, None).expect("reduced output");
589 assert!(output.len() < input.len());
590 }
591
592 #[test]
593 fn passthrough_capture_emits_exact_bytes() {
594 let input = b"exact\xffbytes\n";
595 let mut output = Vec::new();
596 let captured = capture(
597 Cursor::new(input),
598 None,
599 &mut output,
600 CapturedOutput::Passthrough,
601 )
602 .expect("capture");
603 assert_eq!(output, input);
604 assert!(finish_output(captured.output, true, None).is_none());
605 }
606
607 #[test]
608 fn archived_passthrough_waits_for_capture_acknowledgement() {
609 let input = b"deferred passthrough\n";
610 let spool = RawSpool::new().expect("spool");
611 let mut output = Vec::new();
612 let mut captured = capture(
613 Cursor::new(input),
614 Some(spool),
615 &mut output,
616 CapturedOutput::Passthrough,
617 )
618 .expect("capture");
619 assert!(output.is_empty());
620 assert!(!captured.raw_emitted);
621 emit_raw(&mut captured, &mut output, "stdout").expect("emit after acknowledgement");
622 assert_eq!(output, input);
623 }
624
625 #[test]
626 fn archive_spool_failure_emits_the_exact_raw_stream() {
627 let input = b"raw output that exceeds the simulated limit\n";
628 let selected = selected_rule(&["git", "status"]);
629 let spool = RawSpool::failing_after(7).expect("spool");
630 let mut output = Vec::new();
631 let captured = capture(
632 Cursor::new(input),
633 Some(spool),
634 &mut output,
635 CapturedOutput::Reduced(Box::new(
636 StreamReducer::new(&selected.rule).expect("reducer"),
637 )),
638 )
639 .expect("capture");
640 assert_eq!(output, input);
641 assert!(captured.raw_emitted);
642 assert!(captured.raw.is_none());
643 assert!(
644 captured
645 .archive_error
646 .is_some_and(|error| error.contains("archive spool"))
647 );
648 }
649
650 #[test]
651 fn rejects_unselected_direct_children() {
652 let result = run(&["cat".to_owned(), ".env".to_owned()], None);
653 assert_eq!(
654 result,
655 Err("pipeline transforms cannot run as standalone YARP rules".to_owned())
656 );
657 let result = run(&["echo".to_owned(), "secret".to_owned()], None);
658 assert_eq!(
659 result,
660 Err("command is not on the YARP allowlist".to_owned())
661 );
662 }
663
664 #[test]
665 fn rewrite_disagreement_fails_open() {
666 let expected = ExpectedSelection {
667 pack_id: "yarp-builtins".to_owned(),
668 rule_id: "git/status".to_owned(),
669 source_digest: [0_u8; 32],
670 };
671 let selected = select_rule(
672 &["git".to_owned(), "status".to_owned()],
673 &[],
674 Some(&expected),
675 )
676 .expect("selection");
677 assert!(selected.is_none());
678 }
679}