cargo / assert_cmd / audit
cargo : assert_cmd @ 2.2.2
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

build-exec-deterministicbuild-exec-minimalbuild-exec-no-networkbuild-exec-no-write-outbuild-exec-safeconcurrency-documentedconcurrency-safeenvironment-safeexec-safefilesystem-safehas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignuses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

assert_cmd 2.2.2 is a Rust test-helper crate wrapping std::process::Command for asserting on CLI behaviour; source matches upstream byte-for-byte, no unsafe, minimal build script, no findings. Safe to use as a dev-dependency.

Report

Subject

assert_cmd is a test-helper crate for asserting on the behaviour of command-line programs. It wraps std::process::Command with conveniences for finding a Cargo crate's compiled binaries (via CARGO_BIN_EXE_*), piping stdin, applying a per-invocation timeout (via wait-timeout), and asserting on stdout/stderr/exit-code through the predicates ecosystem. The crate is intended to be used from #[cfg(test)] integration tests.

Methodology

The published crate contents were compared against the upstream Git repository tag assert_cmd-v2.2.2 (commit feece89) using diff -r. All published source files (src/lib.rs, src/assert.rs, src/cargo.rs, src/cmd.rs, src/color.rs, src/macros.rs, src/output.rs, src/bin/bin_fixture.rs, totalling ~3050 lines), the build.rs, the two examples, and the Cargo.toml/Cargo.toml.orig pair were read. Upstream-only files (CHANGELOG.md, CONTRIBUTING.md, tests/, CI configs) were inspected for integration-test coverage. unsafe and extern "C" were searched with grep -nE. Tools used: openvet 0.x, GNU diff, BSD grep, BSD wc, BSD find.

Results

All published source files match upstream byte-for-byte; Cargo.toml.orig matches the upstream Cargo.toml exactly. The published Cargo.toml differs only by cargo's standard publish-time normalisation. The crate publishes a deliberate include whitelist that excludes the upstream tests/, CI configs, and changelog, justifying the absence of those files from the audit workspace. No proc-macro, no binary artefacts, no install hook (justifying has-binaries, has-install-exec). No obfuscation or unexplained behaviour was observed (justifying is-benign).

The crate has no unsafe and no extern "C" declarations (justifying uses-unsafe). It uses no network, JIT, interpreter, or cryptography (justifying uses-network, uses-jit, uses-interpreter, uses-crypto). It does not implement a parser, interpreter, JIT, protocol, data structure, algorithm, concurrency primitive, or cryptographic primitive (justifying impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-datastructure, impl-algorithm, impl-concurrency).

build.rs (17 lines) writes the cargo $TARGET triple to $OUT_DIR/current_target.txt, which src/cargo.rs reads back via include_str! to construct the CARGO_TARGET_<TRIPLET>_RUNNER env-var lookup key. The build script does no network I/O, writes only inside OUT_DIR, and is a pure function of cargo-provided environment, justifying has-build-exec together with build-exec-safe, build-exec-deterministic, build-exec-no-network, build-exec-no-write-out, build-exec-minimal.

The crate's runtime surface is exactly what its purpose requires: it spawns child processes via std::process::Command in argv form (no shell), reads CARGO_BIN_EXE_*, CARGO_TARGET_*_RUNNER, and the env::current_exe() legacy fallback for binary discovery, and constructs paths from those env vars. Process invocation is argv-only, with arguments and program names supplied by the (trusted) test author; this justifies uses-exec, exec-safe, uses-filesystem, filesystem-safe, uses-environment, environment-safe. Command::output() spawns three short-lived std::thread workers to drive stdin/stdout/stderr while the child runs, and joins them before returning; an optional wait_timeout triggers child.kill() then child.wait() on expiry. This justifies uses-concurrency, concurrency-safe, concurrency-documented (the thread-safety contract is enforced by Send + 'static bounds on the API).

Inline unit tests exist in src/cargo.rs:298 (validating the panic message when CARGO_BIN_EXE_* is unset) and integration tests live in the upstream tests/ directory (excluded from the published crate by the include whitelist but present in the workspace and exercised by CI), justifying has-unit-tests and has-integration-tests. No fuzz or property tests are present, which is appropriate for a thin test-helper library, justifying has-fuzz-tests and has-property-tests.

No findings were recorded.

Conclusion

assert_cmd is a small, mature test-helper crate. The published source matches upstream, the build script is minimal and safe, there is no unsafe code, and every "uses-" claim (exec, filesystem, environment, concurrency) is paired with a documented, safe usage pattern. The crate is safe to use as a dev-dependencies entry.

Findings

No findings.

Annotations(4)

src/bin/bin_fixture.rs

Test fixture binary: reads stdout/stderr/sleep/exit env vars and behaves accordingly. Built by cargo at test time; not part of the published library surface. No outbound effects beyond writing what env vars instructed.

src/cargo.rs

Resolves cargo-built binary paths via the CARGO_BIN_EXE_<name> env var (the canonical post-1.43 cargo contract) with a legacy fallback derived from env::current_exe(). Constructs a process::Command honouring CARGO_TARGET_<TRIPLET>_RUNNER. All env-var names are documented cargo conventions, justifying uses-environment and environment-safe; path operations are bounded to env-derived locations, justifying uses-filesystem and filesystem-safe; process spawn is argv-form via std::process::Command, justifying uses-exec and exec-safe.

src/cmd.rs

src/cmd.rs, line 446-516

    pub fn output(&mut self) -> io::Result<process::Output> {
        let spawn = self.spawn()?;
        Self::wait_with_input_output(spawn, self.stdin.as_deref().cloned(), self.timeout)
    }

    /// If `input`, write it to `child`'s stdin while also reading `child`'s
    /// stdout and stderr, then wait on `child` and return its status and output.
    ///
    /// This was lifted from `std::process::Child::wait_with_output` and modified
    /// to also write to stdin.
    fn wait_with_input_output(
        mut child: process::Child,
        input: Option<Vec<u8>>,
        timeout: Option<std::time::Duration>,
    ) -> io::Result<process::Output> {
        #![allow(clippy::unwrap_used, reason = "changes behavior in some tests")]

        fn read<R>(mut input: R) -> std::thread::JoinHandle<io::Result<Vec<u8>>>
        where
            R: Read + Send + 'static,
        {
            std::thread::spawn(move || {
                let mut ret = Vec::new();
                input.read_to_end(&mut ret).map(|_| ret)
            })
        }

        let stdin = input.and_then(|i| {
            child
                .stdin
                .take()
                .map(|mut stdin| std::thread::spawn(move || stdin.write_all(&i)))
        });
        let stdout = child.stdout.take().map(read);
        let stderr = child.stderr.take().map(read);

        // Finish writing stdin before waiting, because waiting drops stdin.
        stdin.and_then(|t| t.join().unwrap().ok());
        let status = if let Some(timeout) = timeout {
            wait_timeout::ChildExt::wait_timeout(&mut child, timeout)
                .transpose()
                .unwrap_or_else(|| {
                    let _ = child.kill();
                    child.wait()
                })
        } else {
            child.wait()
        }?;

        let stdout = stdout
            .and_then(|t| t.join().unwrap().ok())
            .unwrap_or_default();
        let stderr = stderr
            .and_then(|t| t.join().unwrap().ok())
            .unwrap_or_default();

        Ok(process::Output {
            status,
            stdout,
            stderr,
        })
    }

    fn spawn(&mut self) -> io::Result<process::Child> {
        // stdout/stderr should only be piped for `output` according to `process::Command::new`.
        self.cmd.stdin(process::Stdio::piped());
        self.cmd.stdout(process::Stdio::piped());
        self.cmd.stderr(process::Stdio::piped());

        self.cmd.spawn()
    }

Command::output() + wait_with_input_output spawns three std::thread workers to drive stdin/stdout/stderr concurrently while the child runs; an optional wait_timeout enforces a child-process timeout and falls back to child.kill() then child.wait() on expiry. All threads are joined before the function returns; the child process is the only target of kill(). Justifies uses-concurrency, concurrency-safe, concurrency-documented (Send + 'static bounds enforce the contract via the type system).