cargo / cc / audit
cargo : cc @ 1.2.62
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

concurrency-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-benignparser-impl-correctparser-impl-safeparser-impl-testedunsafe-documentedunsafe-minimalunsafe-safeunsafe-testeduses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

cc 1.2.62 is a build-time C/C++ compiler driver used by most -sys crates. All subprocess invocations use argv form (no shell), writes are confined to OUT_DIR, environment variables are documented and tracked for Cargo rebuild detection, and 19 unsafe blocks were read and found sound. One low-severity quality finding: most unsafe blocks lack canonical // SAFETY: comments.

Report

Subject

cc 1.2.62 is a build-time library for Cargo build scripts that drives the host C/C++ compiler to compile C, C++, assembly, and CUDA sources into static archives. It is used by a large fraction of -sys crates in the ecosystem. The crate's primary surface is the Build struct, which accumulates source files, flags, and environment overrides, then locates an appropriate compiler (cc, gcc, clang, cl.exe, nvcc), constructs an argv-form command line, spawns the compiler, and links the results into a .a or .lib archive. An optional parallel feature enables concurrent compilation coordinated via a Cargo jobserver. The crate itself has no build script (build = false).

Methodology

The published crate contents were compared against the upstream VCS repository at the commit recorded in .cargo_vcs_info.json using diff -rq. The differences were limited to Cargo.toml (cargo normalisation), the absence of tests/, .github/, dev-tools/, and src/bin/ from the published crate (all excluded in Cargo.toml.orig), and standard registry files. No source file divergences were found.

All 16 Rust source files (~7,500 unique lines) were read. The shipped src/detect_compiler_family.c (16 lines) was also read. Source survey grep passes were run for unsafe, Command::new, env::var, filesystem operations, network, concurrency, and cryptographic APIs before reading each file in full. The openvet CLI (0.6.0) was used throughout.

Results

The published contents match the VCS source exactly for all compiled files, justifying is-benign. The crate ships no binary artefacts (has-binaries) and no build.rs (has-build-exec, has-install-exec). 17 unit tests live in src/lib.rs; the integration test suite (excluded from the published crate but present in vcs/tests/) covers compilation behaviour across compilers. has-unit-tests and has-integration-tests are both true. No fuzz tests or property tests were found (has-fuzz-tests, has-property-tests).

The crate invokes compilers, archivers, and build-support tools via std::process::Command. All invocations use the argv form; flags and paths are passed as OsString values, never interpolated into a shell string. The shlex crate splits compiler paths (e.g. CC='sccache cc') and CFLAGS-style variables when CC_SHELL_ESCAPED_FLAGS is set, but the resulting tokens are fed directly as argv elements. There is no shell involvement. This justifies uses-exec and exec-safe.

All filesystem writes are anchored to OUT_DIR (for object files, archives, and flag-probe temporaries) or env::temp_dir (fallback during compiler-family detection only). The NamedTempfile helper creates files with create_new(true), avoiding races, and cleans up on drop. This justifies uses-filesystem and filesystem-safe.

Environment variable access goes through Build::get_env, which emits cargo:rerun-if-env-changed=<name> for each variable read. Cargo-set variables (CARGO_CFG_*, OUT_DIR, TARGET, HOST, NUM_JOBS) are read via a separate cargo_env_var_os helper that intentionally skips the rerun directive since Cargo already tracks those. The documented four-tier lookup scheme (CC_<target>, CC_<target_underscored>, TARGET_CC, CC) is implemented in target_envs. The crate does not enumerate the environment. Justifies uses-environment and environment-safe.

RwLock guards the compiler-family lookup cache; Mutex guards the jobserver's implicit-token state in the parallel feature. The Arc<AtomicBool> in CargoOutput handles the rerun-if-env-changed deduplication flag. No shared mutable state is accessed without synchronisation. Justifies uses-concurrency and concurrency-safe. Thread-safety contracts are not individually documented per type (concurrency-documented false).

Unsafe code appears in three locations: src/utilities.rs (the OnceLock<T> backport), src/parallel/async_executor.rs (Pin::new_unchecked and Waker::from_raw), and src/parallel/stderr.rs (libc::fcntl/ioctl and Windows PeekNamedPipe). Each unsafe block's invariants were read and verified as sound. The OnceLock invariants rely on Once::is_completed() as the initialization guard; Pin::new_unchecked is safe because the futures are stack-pinned and cannot move after the call; the libc calls operate on file descriptors obtained from live child handles. Justifies uses-unsafe and unsafe-safe. All unsafe is necessary for MSRV compatibility and non-blocking I/O; unsafe-minimal is true. However, most unsafe blocks lack canonical // SAFETY: comments (see FINDING-1), justifying unsafe-documented false.

The crate implements a parser for CARGO_ENCODED_RUSTFLAGS (unit-separated codegen flags in src/flags.rs) and a Rust target-triple parser in src/target/parser.rs. Both parsers are well-tested by the integration suite and handle edge cases (unknown flags, short targets). impl-parser, parser-impl-safe, parser-impl-correct, and parser-impl-tested are all true. uses-concurrency is true; impl-concurrency is false (uses standard library primitives, does not implement them). The codebase was reviewed for cryptographic operations, network calls, JIT compilation, and interpreter usage and none was found (uses-crypto, uses-network, uses-jit, uses-interpreter, impl-crypto, impl-jit, impl-interpreter, impl-protocol, impl-datastructure, impl-algorithm). The unsafe blocks were not put through an automated testing tool such as Miri; unsafe-tested is false.

One low-severity quality finding was identified (FINDING-1): missing // SAFETY: comments on the majority of unsafe blocks.

Conclusion

cc 1.2.62 is a widely-used build-time compiler driver with no binary artefacts, no build script, and no network access. Its 19 unsafe blocks are concentrated in an OnceLock backport and parallel-compilation I/O helpers; all were read and found sound. The crate makes deliberate attempts at determinism (ZERO_AR_DATE, the cqD archiver flag, relative-path hashing for object names). The single finding is a quality issue around missing // SAFETY: documentation on unsafe blocks.

Findings(1)

FINDING-1 quality low

Unsafe blocks lack canonical SAFETY comments

The OnceLock<T> implementation in src/utilities.rs contains several unsafe blocks and two unsafe impl items that lack canonical // SAFETY: comments. Specifically:

  • get_unchecked (lines 80-87): the function signature is unsafe fn, and the inner unsafe block has no comment at all.
  • get_or_init (line 91): unsafe { &mut *self.value.get() }.write(f()) has no safety comment. The call site is inside a call_once closure, which guarantees exclusive access; this should be documented.
  • get (line 99): has a brief // Safe b/c checked is_initialized comment but not the // SAFETY: convention.
  • unsafe impl Sync for OnceLock<T> and unsafe impl Send for OnceLock<T> (lines 117-118): no comment explaining why the bounds T: Sync + Send and T: Send are sufficient.
  • parallel/async_executor.rs lines 62-63: Pin::new_unchecked calls have no // SAFETY: comment.
  • parallel/job_token.rs line 41: inherited_jobserver::JobServer::from_env() call is inside unsafe {} with no comment.

The invariants are recoverable from context and the code appears sound, but the lack of systematic // SAFETY: comments reduces auditability. Justifies unsafe-documented.

Annotations(6)

src/command_helpers.rs

src/command_helpers.rs, line 423-460

pub(crate) fn spawn(cmd: &mut Command, cargo_output: &CargoOutput) -> Result<Child, Error> {
    struct ResetStderr<'cmd>(&'cmd mut Command);

    impl Drop for ResetStderr<'_> {
        fn drop(&mut self) {
            // Reset stderr to default to release pipe_writer so that print thread will
            // not block forever.
            self.0.stderr(Stdio::inherit());
        }
    }

    cargo_output.print_debug(&format_args!("running: {cmd:?}"));

    let cmd = ResetStderr(cmd);
    let child = cmd
        .0
        .stderr(cargo_output.stdio_for_warnings())
        .stdout(cargo_output.stdio_for_output())
        .spawn();
    match child {
        Ok(child) => Ok(child),
        Err(ref e) if e.kind() == io::ErrorKind::NotFound => {
            let extra = if cfg!(windows) {
                " (see https://docs.rs/cc/latest/cc/#compile-time-requirements for help)"
            } else {
                ""
            };
            Err(Error::new(
                ErrorKind::ToolNotFound,
                format!("failed to find tool {:?}: {e}{extra}", cmd.0.get_program()),
            ))
        }
        Err(e) => Err(Error::new(
            ErrorKind::ToolExecError,
            format!("command `{:?}` failed to start: {e}", cmd.0),
        )),
    }
}

All subprocess invocations use std::process::Command::new(path) with arguments added via cmd.arg(...), never via shell string interpolation. The compiler path is resolved from the CC/CXX/AR environment variables or built-in defaults; arguments are passed as typed OsString or &str values, never through a shell. This avoids shell injection. The shlex crate is used to split CFLAGS-style env vars when CC_SHELL_ESCAPED_FLAGS is set; it is also used without that flag to split the space-separated compiler path from CC='sccache cc'.

Justifies uses-exec and exec-safe.

src/lib.rs

src/lib.rs, line 3883-3899

    /// Look up an environment variable, and tell Cargo that we used it.
    fn get_env(&self, v: &str) -> Option<OsString> {
        // Excluding `PATH` prevents spurious rebuilds on Windows, see
        // <https://github.com/rust-lang/cc-rs/pull/1215> for details.
        if self.emit_rerun_if_env_changed && v != "PATH" {
            self.cargo_output
                .print_metadata(&format_args!("cargo:rerun-if-env-changed={v}"));
        }
        #[allow(clippy::disallowed_methods)] // We emit rerun-if-env-changed above
        let r = env::var_os(v);
        self.cargo_output.print_metadata(&format_args!(
            "{} = {}",
            v,
            OptionOsStrDisplay(r.as_deref())
        ));
        r
    }

All environment variable reads go through Build::get_env, which emits cargo:rerun-if-env-changed=<var> for any non-PATH variable it reads. The priority order for target-prefixed variables (e.g. CC_x86_64_unknown_linux_gnu > TARGET_CC > CC) is implemented in target_envs and getenv_with_target_prefixes. This is documented in the crate's top-level doc comment. Cargo-set variables (CARGO_CFG_*, OUT_DIR, TARGET, HOST, etc.) are read via cargo_env_var_os, which is explicitly annotated to skip the rerun-if-env-changed directive since Cargo already handles those.

Justifies uses-environment and environment-safe.

src/parallel/async_executor.rs

src/parallel/async_executor.rs, line 55-68

{
    // Shadows the future so that it can never be moved and is guaranteed
    // to be pinned.
    //
    // The same trick used in `pin!` macro.
    //
    // TODO: Once MSRV is bumped to 1.68, replace this with `std::pin::pin!`
    let mut fut1 = Some(unsafe { Pin::new_unchecked(&mut fut1) });
    let mut fut2 = Some(unsafe { Pin::new_unchecked(&mut fut2) });

    // TODO: Once `Waker::noop` stablised and our MSRV is bumped to the version
    // which it is stablised, replace this with `Waker::noop`.
    let waker = unsafe { Waker::from_raw(NOOP_RAW_WAKER) };
    let mut context = Context::from_waker(&waker);

Two unsafe { Pin::new_unchecked(&mut fut) } calls. The safety invariant required is that the futures never move after pinning. This is satisfied by let mut fut1 = Some(...) being shadowed immediately and the Some wrapper preventing re-use after polling completes. The Waker::from_raw(NOOP_RAW_WAKER) call constructs a waker from a vtable whose clone/wake/drop functions are all valid no-ops (no allocation, so drop is a no-op; clone returns the same no-op waker). The NOOP_RAW_WAKER uses a null data pointer, which is valid per the waker contract when the vtable functions ignore the data pointer entirely.

Justifies uses-unsafe for the mini async executor.

src/parallel/stderr.rs

src/parallel/stderr.rs, line 10-91

#[cfg(unix)]
fn get_flags(fd: std::os::unix::io::RawFd) -> Result<i32, Error> {
    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL, 0) };
    if flags == -1 {
        Err(Error::new(
            ErrorKind::IOError,
            format!(
                "Failed to get flags for pipe {}: {}",
                fd,
                std::io::Error::last_os_error()
            ),
        ))
    } else {
        Ok(flags)
    }
}

#[cfg(unix)]
fn set_flags(fd: std::os::unix::io::RawFd, flags: std::os::raw::c_int) -> Result<(), Error> {
    if unsafe { libc::fcntl(fd, libc::F_SETFL, flags) } == -1 {
        Err(Error::new(
            ErrorKind::IOError,
            format!(
                "Failed to set flags for pipe {}: {}",
                fd,
                std::io::Error::last_os_error()
            ),
        ))
    } else {
        Ok(())
    }
}

#[cfg(unix)]
pub fn set_non_blocking(pipe: &impl std::os::unix::io::AsRawFd) -> Result<(), Error> {
    // On Unix, switch the pipe to non-blocking mode.
    // On Windows, we have a different way to be non-blocking.
    let fd = pipe.as_raw_fd();

    let flags = get_flags(fd)?;
    set_flags(fd, flags | libc::O_NONBLOCK)
}

pub fn bytes_available(stderr: &mut ChildStderr) -> Result<usize, Error> {
    let mut bytes_available = 0;
    #[cfg(windows)]
    {
        use ::find_msvc_tools::windows_sys::PeekNamedPipe;
        use std::os::windows::io::AsRawHandle;
        use std::ptr::null_mut;
        if unsafe {
            PeekNamedPipe(
                stderr.as_raw_handle(),
                null_mut(),
                0,
                null_mut(),
                &mut bytes_available,
                null_mut(),
            )
        } == 0
        {
            return Err(Error::new(
                ErrorKind::IOError,
                format!(
                    "PeekNamedPipe failed with {}",
                    std::io::Error::last_os_error()
                ),
            ));
        }
    }
    #[cfg(unix)]
    {
        use std::os::unix::io::AsRawFd;
        if unsafe { libc::ioctl(stderr.as_raw_fd(), libc::FIONREAD, &mut bytes_available) } != 0 {
            return Err(Error::new(
                ErrorKind::IOError,
                format!("ioctl failed with {}", std::io::Error::last_os_error()),
            ));
        }
    }
    Ok(bytes_available.try_into().unwrap())
}

Unix-only: uses libc::fcntl and libc::ioctl to set O_NONBLOCK and query FIONREAD on child stderr file descriptors. The fd is obtained from ChildStderr::as_raw_fd(), which is always valid for a live child process, and the results are checked against -1/!=0 with errno forwarded as errors. Windows path uses PeekNamedPipe via the find-msvc-tools bindings with a valid handle obtained from ChildStderr::as_raw_handle().

Justifies uses-unsafe for the parallel module's libc calls.

src/tempfile.rs

All filesystem writes use paths anchored to OUT_DIR (from the CARGO_OUT_DIR env var or the Build::out_dir setter): compiled .o files in objects_from_files, the assembled archive in assemble, and temporary files for flag-probe and compiler-family-detection in is_flag_supported_inner and detect_family_inner. The NamedTempfile helper writes to the path passed in (always OUT_DIR or env::temp_dir fallback). No path-traversal checks are needed because the destination paths are constructed by PathBuf::join on a controlled base.

Justifies uses-filesystem and filesystem-safe.

src/utilities.rs

src/utilities.rs, line 66-131

impl<T> OnceLock<T> {
    pub(crate) const fn new() -> Self {
        Self {
            once: Once::new(),
            value: UnsafeCell::new(MaybeUninit::uninit()),
            _marker: PhantomData,
        }
    }

    #[inline]
    fn is_initialized(&self) -> bool {
        self.once.is_completed()
    }

    unsafe fn get_unchecked(&self) -> &T {
        debug_assert!(self.is_initialized());
        #[allow(clippy::needless_borrow)]
        #[allow(unused_unsafe)]
        unsafe {
            (&*self.value.get()).assume_init_ref()
        }
    }

    pub(crate) fn get_or_init(&self, f: impl FnOnce() -> T) -> &T {
        self.once.call_once(|| {
            unsafe { &mut *self.value.get() }.write(f());
        });
        unsafe { self.get_unchecked() }
    }

    pub(crate) fn get(&self) -> Option<&T> {
        if self.is_initialized() {
            // Safe b/c checked is_initialized
            Some(unsafe { self.get_unchecked() })
        } else {
            None
        }
    }
}

impl<T: fmt::Debug> fmt::Debug for OnceLock<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut d = f.debug_tuple("OnceLock");
        match self.get() {
            Some(v) => d.field(v),
            None => d.field(&format_args!("<uninit>")),
        };
        d.finish()
    }
}

unsafe impl<T: Sync + Send> Sync for OnceLock<T> {}
unsafe impl<T: Send> Send for OnceLock<T> {}

impl<T: RefUnwindSafe + UnwindSafe> RefUnwindSafe for OnceLock<T> {}
impl<T: UnwindSafe> UnwindSafe for OnceLock<T> {}

impl<T> Drop for OnceLock<T> {
    #[inline]
    fn drop(&mut self) {
        if self.once.is_completed() {
            // SAFETY: The cell is initialized and being dropped, so it can't
            // be accessed again.
            unsafe { self.value.get_mut().assume_init_drop() };
        }
    }

Custom OnceLock<T> implementation using UnsafeCell<MaybeUninit<T>> and std::sync::Once. The get_unchecked method is marked unsafe fn and requires is_initialized() to be true, enforced by each of its two call sites. The Sync/Send impls are gated on T: Sync + Send and T: Send respectively, which matches the standard OnceLock contract. The Drop impl calls assume_init_drop only after confirming is_completed(). The implementation is a faithful backport of std::sync::OnceLock to support MSRV 1.63 (before OnceLock was stabilised in 1.70).

Justifies uses-unsafe and unsafe-safe (for this module).