ptuf 0.5.0

PreToolUseFilter: a generic guardrail layer for coding agents
Documentation
//! Process-spawning seam used by `ptuf update`.
//!
//! Two flavours: `run` buffers stdout/stderr so the caller can parse the
//! response (used for the curl `Location:` probe and the `cargo --version`
//! detection); `run_inherited` lets the child inherit ptuf's stdio so the
//! user sees `cargo install` / installer progress live. Tests inject
//! `RecordingSpawner` to drive both without touching the real filesystem.

use std::io;
use std::process::Command;

#[derive(Debug)]
pub struct SpawnOutcome {
    pub exit_code: i32,
    pub stdout: Vec<u8>,
    pub stderr: Vec<u8>,
}

pub trait Spawner {
    /// Run `program` with `args`, capturing stdout/stderr into the outcome.
    /// Use for short probes whose output ptuf parses (curl headers,
    /// `cargo --version`).
    fn run(&self, program: &str, args: &[&str]) -> io::Result<SpawnOutcome>;

    /// Run `program` with `args`, inheriting ptuf's stdin/stdout/stderr so
    /// the user sees live progress. Returns the child's exit code only.
    /// Use for the actual updater spawn (`cargo install ptuf --force`,
    /// `sh -c "curl ... | sh"`).
    fn run_inherited(&self, program: &str, args: &[&str]) -> io::Result<i32>;
}

#[derive(Debug, Default)]
pub struct ProcessSpawner;

impl Spawner for ProcessSpawner {
    fn run(&self, program: &str, args: &[&str]) -> io::Result<SpawnOutcome> {
        let output = Command::new(program).args(args).output()?;
        Ok(SpawnOutcome {
            // Signal-terminated children have no exit code; treat as
            // failure so `ptuf update` doesn't silently report success.
            exit_code: output.status.code().unwrap_or(1),
            stdout: output.stdout,
            stderr: output.stderr,
        })
    }

    fn run_inherited(&self, program: &str, args: &[&str]) -> io::Result<i32> {
        let status = Command::new(program).args(args).status()?;
        Ok(status.code().unwrap_or(1))
    }
}

#[cfg(test)]
pub mod testing {
    use std::cell::RefCell;
    use std::io;

    use super::{SpawnOutcome, Spawner};

    #[derive(Debug)]
    pub struct RecordedCall {
        pub program: String,
        pub args: Vec<String>,
    }

    pub struct RecordingSpawner {
        outcomes: RefCell<Vec<io::Result<SpawnOutcome>>>,
        calls: RefCell<Vec<RecordedCall>>,
    }

    impl RecordingSpawner {
        pub fn new(outcomes: Vec<io::Result<SpawnOutcome>>) -> Self {
            Self {
                outcomes: RefCell::new(outcomes),
                calls: RefCell::new(Vec::new()),
            }
        }

        pub fn calls(&self) -> Vec<RecordedCall> {
            self.calls
                .borrow()
                .iter()
                .map(|c| RecordedCall {
                    program: c.program.clone(),
                    args: c.args.clone(),
                })
                .collect()
        }
    }

    impl Spawner for RecordingSpawner {
        fn run(&self, program: &str, args: &[&str]) -> io::Result<SpawnOutcome> {
            self.calls.borrow_mut().push(RecordedCall {
                program: program.to_string(),
                args: args.iter().map(|s| (*s).to_string()).collect(),
            });
            let mut outcomes = self.outcomes.borrow_mut();
            if outcomes.is_empty() {
                return Err(io::Error::other("RecordingSpawner exhausted"));
            }
            outcomes.remove(0)
        }

        fn run_inherited(&self, program: &str, args: &[&str]) -> io::Result<i32> {
            // Tests reuse the same outcome queue; only the exit code
            // matters because production discards stdout/stderr (the
            // child inherits ptuf's stdio).
            self.run(program, args).map(|o| o.exit_code)
        }
    }

    pub fn ok(stdout: &str) -> io::Result<SpawnOutcome> {
        Ok(SpawnOutcome {
            exit_code: 0,
            stdout: stdout.as_bytes().to_vec(),
            stderr: Vec::new(),
        })
    }

    pub fn ok_with_stderr(exit_code: i32, stderr: &str) -> io::Result<SpawnOutcome> {
        Ok(SpawnOutcome {
            exit_code,
            stdout: Vec::new(),
            stderr: stderr.as_bytes().to_vec(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn process_spawner_returns_exit_code_and_streams() {
        let spawner = ProcessSpawner;
        let outcome = spawner.run("true", &[]).expect("true should run");
        assert_eq!(outcome.exit_code, 0);
        assert!(outcome.stdout.is_empty());
        assert!(outcome.stderr.is_empty());
    }

    #[test]
    fn process_spawner_propagates_nonzero_exit_code() {
        let spawner = ProcessSpawner;
        let outcome = spawner.run("false", &[]).expect("false should run");
        assert_eq!(outcome.exit_code, 1);
    }

    #[test]
    fn process_spawner_returns_io_error_when_program_missing() {
        let spawner = ProcessSpawner;
        let err = spawner
            .run("ptuf-nonexistent-binary-xyz", &[])
            .expect_err("missing program must surface as io::Error");
        assert_eq!(err.kind(), io::ErrorKind::NotFound);
    }

    #[test]
    fn process_spawner_inherited_returns_exit_code_only() {
        let spawner = ProcessSpawner;
        let code = spawner.run_inherited("true", &[]).expect("true should run");
        assert_eq!(code, 0);
    }

    #[test]
    fn process_spawner_inherited_propagates_nonzero_exit_code() {
        let spawner = ProcessSpawner;
        let code = spawner
            .run_inherited("false", &[])
            .expect("false should run");
        assert_eq!(code, 1);
    }
}