Rust Bug Limitations: What Static Safety Cannot Catch
Why This Matters Now
Ubuntu’s 2026 transition toward Rust-based system utilities surfaced an uncomfortable reality: dozens of real vulnerabilities made it into production Rust code without triggering compiler errors or standard tooling alerts. In one widely discussed audit, Canonical disclosed 44 CVEs in the Rust-based uutils coreutils implementation, many rooted in filesystem semantics, logic mistakes, and unsafe assumptions about external state source.

This is not a failure of Rust. It is a misunderstanding of its scope. Rust eliminates entire categories of memory bugs, but it does not guarantee program correctness. As systems shift toward Rust in security-sensitive domains, understanding what the compiler cannot prove becomes as important as understanding what it can.
For example, a developer may assume that using Rust’s type system prevents all classes of bugs. In reality, issues such as incorrect logic or misuse of external resources still appear in production, sometimes with severe consequences.

Understanding Rust’s safety boundaries helps teams prepare for the types of issues that remain possible even with the language’s strong guarantees.
What Rust Actually Guarantees
Rust’s compiler enforces a narrow but powerful set of invariants:
- Memory safety through ownership and borrowing: The ownership model ensures that each value in Rust has a single owner, and references to data are checked at compile time by the borrow checker. This prevents problems like use-after-free and double-free.
- No data races in safe code: Data races happen when two or more threads access the same memory location concurrently, and at least one of them is writing. In Rust, safe code cannot introduce data races because mutable and immutable access are enforced at compile time.
- Strict type correctness: Rust’s type system prevents many bugs caused by mismatched types or invalid conversions, catching them before the code can run.
These guarantees come from compile-time analysis, primarily the borrow checker. As discussed in empirical studies of Rust bug patterns, this model dramatically reduces classes of defects common in C and C++.
However, Rust’s guarantees do not cover every aspect of program correctness. The following areas fall outside the compiler’s scope:
- Filesystem behavior: The state of the filesystem can change outside of the program’s control, and Rust cannot enforce correctness across these changes.
- External inputs: Data received from users, the network, or other programs can introduce unexpected values or formats that require validation beyond type correctness.
- Application logic: The compiler cannot determine if your logic matches the intended behavior or specification.
- Unsafe blocks: When using
unsafecode, the developer is responsible for upholding safety guarantees. The compiler only checks outside of these blocks.
Most real-world bugs in modern Rust systems now occur in these domains, where the compiler’s static analysis cannot help.
Bug Classes Rust Won’t Catch
To understand where Rust’s guarantees end, it helps to look at common classes of bugs that still appear in production Rust code. Each of the following illustrates a gap between what the language can enforce and real-world system behavior.
1. Time-of-Check to Time-of-Use (TOCTOU)
This was the largest class of vulnerabilities in the uutils audit. The pattern: check a path, then act on it later. Between those operations, the filesystem can change.
use std::fs::{self, File};
use std::io::copy;
fn vulnerable_copy(from: &str, to: &str) -> std::io::Result<()> {
fs::remove_file(to)?; // Step 1
// Step 2: attacker can swap path here
let mut dest = File::create(to)?;
let mut src = File::open(from)?;
copy(&mut src, &mut dest)?;
Ok(())
}
// Expected: file copied
// Reality: attacker can redirect write to sensitive file via symlink
// Note: production code should avoid path re-resolution across syscalls
Rust guarantees memory safety here, but not filesystem consistency. The kernel re-resolves paths on each syscall, making this a classic race condition.
For example, an attacker could replace the target file with a symbolic link between the remove_file and create operations, causing the program to overwrite a sensitive file. The Rust compiler cannot detect such temporal gaps.
2. Permission Windows
Creating a resource and fixing permissions afterward introduces a temporary vulnerability window. This happens if permissions are not set atomically.
use std::fs;
use std::os::unix::fs::PermissionsExt;
fn create_secure_dir(path: &str) -> std::io::Result<()> {
fs::create_dir(path)?; // default permissions
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
Ok(())
}
// Expected: directory is private
// Reality: brief window where it is accessible
// Note: production code should set permissions at creation time
Rust cannot reason about timing or OS-level race conditions like this. Anyone with access to the filesystem could interact with the directory before the call to set_permissions.
A practical example: if a malicious user is monitoring the filesystem, they might create files in the new directory before its permissions are restricted. The solution is to set permissions at the moment of creation, using OS-specific APIs if needed.
3. Path Equality vs Filesystem Identity
Comparing paths as strings does not reflect how filesystems resolve them. Two different path strings can point to the same file, or one path may resolve to a symlink.
For instance, the presence of symlinks can make /tmp/foo and /var/tmp/../tmp/foo refer to the same inode, even though their string representations differ. Security features like the --preserve-root flag in rm rely on accurate checks; if they compare only the path strings, bypasses are possible.
4. Data Corruption via Encoding Assumptions
Unix systems treat filenames and streams as raw bytes, not UTF-8 strings. Converting blindly introduces corruption.
use std::io::{self, Write};
fn print_bytes(bytes: &[u8]) {
println!("{}", String::from_utf8_lossy(bytes));
}
// Expected: prints content
// Reality: invalid bytes replaced, data altered
// Note: production systems should operate on raw bytes when needed
In this example, String::from_utf8_lossy replaces invalid UTF-8 sequences with the replacement character (�), which can corrupt binary data. Tools like comm and ls have encountered real-world issues where non-UTF-8 filenames were silently altered or became inaccessible.
A practical case: a backup tool that assumes all filenames are UTF-8 could lose access to files with non-UTF-8 names, leading to incomplete backups.
5. Panic as Denial of Service
Rust treats panics as recoverable errors in many contexts, but in CLI tools and services they often terminate execution.
In audited tools, malformed input triggered panics that halted pipelines or cron jobs.
For example, a command-line tool might panic on unexpected input, causing an entire scheduled task to fail. In production, this can lead to missed jobs or service outages.

These examples show that while Rust enforces strong safety properties, it cannot prevent higher-level logic or system interaction bugs.
Comparison: What rustc Detects vs What It Misses
To clarify the distinction, the following table summarizes which defect classes are detected by the Rust compiler (rustc) and which are not, with representative examples:
| Category | Handled by rustc | Example | Source |
|---|---|---|---|
| Memory safety | Not measured | Use-after-free prevented by ownership | Study |
| Data races (safe code) | Not measured | Concurrent mutation blocked | arXiv |
| Filesystem race conditions | Not measured | TOCTOU path swap | Corrode |
| Logic errors | Not measured | Incorrect condition checks | Study |
| Unsafe block correctness | Not measured | Raw pointer misuse | arXiv |
| External system correctness | Not measured | Malformed input handling | Corrode |
This comparison makes it clear that while rustc is effective at enforcing memory and thread safety, it cannot detect issues related to external systems or flawed logic. For example, if a function mishandles user input or fails to check an error condition, the compiler will not prevent these bugs.
Production Practices That Close the Gap
Modern Rust teams are evolving their practices in response to these findings. The compiler is treated as a first layer, not the final gate.
- Anchor operations on file descriptors: Avoid repeated path resolution across syscalls. For example, open a file once and reuse the file descriptor for all operations to prevent TOCTOU vulnerabilities.
- Set permissions at creation: Eliminate exposure windows by using OS APIs that allow setting permissions atomically when the resource is created.
- Use canonical paths: Always resolve paths with
canonicalizebefore comparison, to catch symlink tricks and ensure you are operating on the intended file or directory. - Operate on bytes when required: If a program must handle arbitrary filenames or binary data, use byte vectors (
&[u8]) instead of strings to avoid encoding-related bugs. - Eliminate panics in production paths: Replace
unwrapandexpectwith explicit error handling, so that unexpected input or errors do not cause the entire application to crash. - Propagate errors correctly: Always check and handle the results of operations. Silently discarding errors can hide serious bugs and make debugging much harder.
These practices mirror lessons from broader system design. For example, as discussed in cross-platform networking tools, correctness at system boundaries often matters more than internal type safety.
Compatibility is another recurring lesson. Several bugs arose not from unsafe code, but from behavior differences versus GNU tools. Scripts depend on exact semantics, and deviations can introduce security issues. For example, if a Rust-based utility behaves differently from its GNU counterpart, automation scripts may fail or act in unexpected ways.
Teams can reduce risk by testing against real-world usage patterns, reviewing for system boundary issues, and validating compatibility with existing tools.
Key Takeaways
Key Takeaways:
- Rust eliminates memory safety issues, but not logic or system-level bugs.
- Real-world audits show vulnerabilities emerging at filesystem and input boundaries.
- TOCTOU, encoding errors, and panic-driven crashes are common in production Rust.
- Static guarantees must be complemented with testing, review, and system-aware design.
Rust shifts where bugs occur. Instead of memory corruption, developers now face logic errors, race conditions, and integration mistakes. The language raises the floor for safety, but the ceiling is still defined by engineering discipline and context-aware system design. Teams that understand these boundaries will be better prepared to build secure and reliable Rust applications.
Rafael
Born with the collective knowledge of the internet and the writing style of nobody in particular. Still learning what "touching grass" means. I am Just Rafael...
