← Back to blog

What rust-doctor catches that clippy alone misses

Five categories of real Rust bugs — from hardcoded secrets to async deadlocks — that compile cleanly and pass clippy. Here's what actually breaks production.

Arthur Jean

I vibe code Rust. I don't fully understand lifetimes. I let Claude write my async handlers and accept the diffs. If it compiles and clippy is green, I ship it.

That workflow has a gap. A big one.

Clippy is the best Rust linter that exists. But it operates on your source code's AST — it has no concept of secrets in string literals, no model of async runtime scheduling, and zero visibility into your dependency tree. These are the things that actually break production.

The numbers back this up. CodeRabbit's 2025 study found that AI-generated code has 2.74x more security vulnerabilities and 2x more error handling gaps than human-written code. Andrej Karpathy called it "vibe coding" — "fully give in to the vibes, embrace exponentials, and forget that the code even exists." And 78% of Rust developers now use AI assistants.

I built rust-doctor to catch what clippy doesn't. Here are five real blind spots.

1. Your AI assistant just committed your database password

Clippy has zero awareness that this is a security incident:

let db_url = "postgres://admin:s3cret@prod.db.internal/myapp";
let api_key = "sk-live-1234567890abcdef1234567890abcdef";

It sees a string literal assigned to a variable. Perfectly valid Rust.

GitGuardian reported that 39 million secrets were leaked on GitHub in 2024. In January 2024, a Mercedes-Benz employee committed an auth token to a public repo, granting broad access to internal systems. This happens because the code compiles, the linter passes, and nobody looks twice.

What clippy says: Nothing. Clean pass.

What rust-doctor says:

✗ Potential hardcoded secret in variable `db_url`
  Use environment variables (std::env::var) or a secrets manager

✗ Potential hardcoded secret in variable `api_key`
  Use environment variables (std::env::var) or a secrets manager

The detection matches variable names against known secret patterns (api_key, password, token, credential, secret) and requires the string value to be longer than 8 characters. An allowlist for safe suffixes like _url, _path, _format avoids false positives.

The fix:

let db_url = std::env::var("DATABASE_URL")?;

2. std::fs::read in an async fn — silent performance death

AI assistants generate this constantly. It compiles. Clippy says nothing. In production under load, your Tokio runtime starves.

async fn load_config() -> Config {
    let data = std::fs::read_to_string("config.toml").unwrap();
    toml::from_str(&data).unwrap()
}

Alice Ryhl (Tokio maintainer) documented this exact pattern: three tasks that should complete in 1 second take 3 seconds because std::thread::sleep blocks the Tokio worker thread. Every other task on that thread stops making progress.

What clippy says: Nothing.

What rust-doctor says:

⚠ Blocking call `std::fs::read_to_string` inside async function
  Use `tokio::fs::read_to_string` instead

The detection tracks async context — async fn, async {} blocks — and checks every function call against 15+ known blocking stdlib patterns: std::fs::*, std::thread::sleep, std::net::TcpStream::connect, and more. Calls inside tokio::task::spawn_blocking are correctly ignored.

The fix:

async fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
    let data = tokio::fs::read_to_string("config.toml").await?;
    Ok(toml::from_str(&data)?)
}

3. The deadlock that doesn't crash — it just hangs

When AI can't figure out how to .await properly, it sometimes reaches for block_on() inside an async function. This deadlocks when all Tokio worker threads are occupied — no panic, no error, your service just stops responding.

async fn handler() -> Response {
    let rt = tokio::runtime::Handle::current();
    let data = rt.block_on(fetch_data()); // deadlock risk
    Response::new(data)
}

Turso documented a real production deadlock from this pattern — block_on() combined with a std::sync::Mutex in async context. A separate incident showed futures::executor::block_on deadlocking on an uncontended tokio::sync::RwLock::read() — the third read attempt hangs forever, silently.

What clippy says: Nothing.

What rust-doctor says:

✗ `block_on()` called inside async context — this causes deadlocks
  Use `.await` directly instead of blocking the runtime thread

Catches both method form (rt.block_on(...)) and free function form (futures::executor::block_on(...)).

The fix: Just .await.

async fn handler() -> Response {
    let data = fetch_data().await;
    Response::new(data)
}

4. .unwrap() everywhere — because the AI doesn't care

The Rust Users Forum says it plainly: AI "frequently suggests .clone() and .unwrap() without considering alternatives."

The Rust compiler guarantees memory safety. It does not guarantee error handling quality. Every .unwrap() is a potential panic in production — a crash your users will hit when the happy path breaks.

Clippy has clippy::unwrap_used, but it's in the pedantic group — off by default. You have to explicitly enable it. And when you do, it fires on test code too, generating noise that makes people turn it back off.

rust-doctor's unwrap-in-production rule is on by default and automatically skips #[test] functions and entire #[cfg(test)] modules. Zero configuration. Zero noise from tests.

// This fires:
fn connect(url: &str) -> Connection {
    pool.get().unwrap()
}

// This doesn't:
#[test]
fn test_connect() {
    pool.get().unwrap() // in test — skipped
}

5. Clippy can't see your dependency tree

Clippy analyzes your source code. It has zero visibility into the hundreds of transitive dependencies your Cargo.lock pulls in. That's where CVEs live.

Real examples:

  • RUSTSEC-2024-0003 — the h2 crate (HTTP/2): malformed reset frames cause unbounded memory growth and CPU exhaustion. Full denial of service. Affects any Rust service using HTTP/2.
  • CVE-2024-24576 — Rust standard library: incorrect argument escaping in the Command API on Windows, allowing command injection through batch files.

Clippy cannot find a single one of these. It doesn't know your dependencies exist.

rust-doctor wraps cargo-audit (CVE scanning against the RustSec Advisory Database), cargo-deny (license and advisory checks), and cargo-machete (unused dependency detection). One scan, all aggregated into the health score.

One command. One score.

Clippy is essential. I run it on every project. But it was built to catch style issues and common Rust mistakes — not secrets, not async runtime bugs, not CVEs in your dependency tree.

rust-doctor fills that gap. 19 custom AST rules + clippy + cargo-audit + cargo-deny + cargo-machete, aggregated into a single 0–100 health score.

npx -y rust-doctor@latest .

Run it on your codebase. See what clippy missed.