In April 2026, Canonical disclosed 44 CVEs in uutils, the Rust reimplementation of GNU coreutils that has been the default in Ubuntu since 25.10. The disclosures came out of an external audit commissioned ahead of the 26.04 LTS release. Most of the bugs were found by code review of a single Rust codebase. None of them were caught by the borrow checker, by clippy lints, or by cargo audit.
The audit is the sharpest case study available for what Rust catches and what it doesn’t. The most useful read on the list is Matthias Endler’s Bugs Rust Won’t Catch, published April 29 on the corrode.dev blog. Endler runs corrode, a Rust consultancy; he hosts the Rust in Production podcast and recently had Canonical’s VP of Engineering, Jon Seager, on it. His piece is the analytical companion to the Canonical disclosure: 44 CVEs, sorted into eight categories, with the actual git diff for the fix on most of them.
What follows takes Endler’s enumeration as the spine and stacks two more arguments on top — the GNU-coreutils-maintainer testimony from the HN thread, which lands a benchmark that Endler’s preferred fix doesn’t survive, and the structural argument about what 40 years of accreted POSIX scars do to any rewrite, regardless of language.
What the audit caught, by category
Endler enumerates eight categories. The shape of each is the same: a Rust idiom that the type system endorses, applied in a context where the type system can’t see what’s wrong.
TOCTOU on path operations is the largest cluster, and the reason cp, mv, and rm are still the GNU implementations in 26.04 LTS rather than uutils. The pattern is one syscall to check, another to act, both taking a &Path. Between them, an attacker with write access to a parent directory can swap the path component for a symlink, the kernel re-resolves on the second call, and the privileged action lands on the attacker’s chosen target. The clearest case is CVE-2026-35355 in install:
// 1. Clear the destination
fs::remove_file(to)?;
// ...
// 2. Re-resolves the path. Follows symlinks, truncates.
let mut dest = File::create(to)?;
copy(from, &mut dest)?;
Anyone with write access to the parent directory can plant to as a symlink to /etc/shadow between steps 1 and 2; the privileged process happily overwrites it with whatever from contains. The fix uses OpenOptions::create_new(true), which the docs explicitly say “No file is allowed to exist at the target location, **also no (dangling) symlink.”
Permission-set-after-create is the close TOCTOU relative. fs::create_dir(&path)?; fs::set_permissions(&path, ...)?; exposes a window where the directory exists with default permissions and any local user can open() it for a file descriptor that survives the later chmod. The fix is OpenOptions::mode() and DirBuilderExt::mode() so the file or directory is born with the permissions you wanted.
Path string equality is not filesystem identity. The original --preserve-root check in chmod was literally if recursive && preserve_root && file == Path::new("/") { return Err(PreserveRoot); }. That comparison is bypassed by anything that resolves to / but isn’t spelled / — /../, /./, a symlink, anything canonicalize would resolve. Run chmod -R 000 /../ and watch it lock down the whole system. The category’s most absurd member is CVE-2026-35363:
rm . # ❌
rm .. # ❌
rm ./ # ✅
rm ./// # ✅
rm rejected the bare . and .. but happily accepted ./, deleted the current directory, then printed “Invalid input.” The string comparison didn’t survive a trailing slash.
UTF-8 versus raw bytes at Unix boundaries. Rust’s String and &str are always UTF-8. Unix paths, environment variables, and the byte streams flowing through tools like cut, comm, and tr are not. Every place a Rust program bridges the two, it has three choices: lossy conversion (which silently rewrites invalid bytes to U+FFFD — fancy data corruption, in Endler’s phrasing), strict conversion (which crashes on the first non-UTF-8 byte), or staying in OsStr / &[u8]. The audit found bugs in both of the first two. CVE-2026-35346 in comm used String::from_utf8_lossy, so passing a binary file through comm silently mangled the output. The fix replaced print! with BufWriter::write_all, staying in bytes.
Panic-as-DoS. Every unwrap, every expect, every slice index, every unchecked arithmetic operation in input-handling code is a potential denial of service if an attacker can shape the input. CVE-2026-35348 in sort --files0-from called expect() on a UTF-8 conversion of each filename:
$ python3 -c "open('list0','wb').write(b'weirdxffname