Skip to main content

yarp_cli/
runner.rs

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
111/// Run one command selected by the built-in rule registry.
112///
113/// # Errors
114///
115/// Returns an error when the command is not selected, cannot run, or its output cannot be read.
116pub 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
123/// Run one command with explicit rule packs and optional rewrite-process agreement metadata.
124///
125/// A changed or unavailable pack after rewrite selects exact pass-through output. Direct invocations
126/// without agreement metadata still reject unsupported commands.
127///
128/// # Errors
129///
130/// Returns an error when direct execution is not selected, the child cannot run, or output cannot
131/// be read or written.
132pub 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}