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
104/// Run one command selected by the built-in rule registry.
105///
106/// # Errors
107///
108/// Returns an error when the command is not selected, cannot run, or its output cannot be read.
109pub 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
116/// Run one command with explicit rule packs and optional rewrite-process agreement metadata.
117///
118/// A changed or unavailable pack after rewrite selects exact pass-through output. Direct invocations
119/// without agreement metadata still reject unsupported commands.
120///
121/// # Errors
122///
123/// Returns an error when direct execution is not selected, the child cannot run, or output cannot
124/// be read or written.
125pub 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}