Skip to content
HN On Hacker News ↗

Bugs Rust Won't Catch

▲ 680 points 372 comments by lwhsiao 4w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully human-written

9 %

AI likelihood · overall

Human
100% human-written 0% AI-generated
SEGMENTS · HUMAN 5 of 6
SEGMENTS · AI 0 of 6
WORD COUNT 1,773
PEAK AI % 42% · §6
Analyzed
Apr 29
backend: pangram/v3.3
Segments scanned
6 windows
avg 296 words each
Distribution
100 / 0%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,773 words · 6 segments analyzed

Human AI-generated
§1 Human · 1%

In April 2026, Canonical disclosed 44 CVEs in uutils, the Rust reimplementation of GNU coreutils that ships by default since 25.10. Most of them came out of an external audit commissioned ahead of the 26.04 LTS.I read through the list and thought there’s a lot to learn from it.What’s notable is that all of these bugs landed in a production Rust codebase, written by people who knew what they were doing, and none of them were caught by the borrow checker, clippy lints, or cargo audit.I’m not writing this to criticize the uutils team. Quite the contrary; I actually want to thank them for sharing the audit results in such detail so that we can all learn from them.We also had Jon Seager, VP Engineering for Ubuntu, on our ‘Rust in Production’ podcast recently and a lot of listeners appreciated his honesty about the state of Rust at Canonical.If you write systems code in Rust, this is the most concentrated look at where Rust’s safety ends that you’ll likely find anywhere right now.Don’t Trust a Path Across Two SyscallsThis is the largest cluster of bugs in the audit. It’s also the reason cp, mv, and rm are still GNU in Ubuntu 26.04 LTS. :(The pattern is always the same. You do one syscall to check something about a path, then another syscall to act on the same path. Between those two calls, an attacker with write access to a parent directory can swap the path component for a symbolic link. The kernel re-resolves the path from scratch on the second call, and the privileged action lands on the attacker’s chosen target.Rust’s standard library makes this easy to get wrong. The ergonomic APIs you reach for first (fs::metadata, File::create, fs::remove_file, fs::set_permissions) all take a path and re-resolve it every time, rather than taking a file descriptor and operating relative to that. That’s fine for a normal program, but if you’re writing a privileged tool that needs to be secure against local attackers, you have to be careful.Case Study: CVE-2026-35355Here’s the bug, simplified from src/uu/install/src/install.rs.// 1. Clear the destination fs::remove_file(to)?;

§2 Human · 7%

// ...

// 2. Create the destination. The path is re-resolved here! let mut dest = File::create(to)?; // follows symlinks, truncates copy(from, &mut dest)?;Between step 1 and step 2, anyone with write access to the parent directory can plant to as a symlink to, say, /etc/shadow. Then File::create follows the symlink and the privileged process happily overwrites /etc/shadow with whatever from happened to contain.The fix uses OpenOptions::create_new(true):fs::remove_file(to)?;

let mut dest = OpenOptions::new() .write(true) .create_new(true) .open(to)?; copy(from, &mut dest)?;The docs for create_new say (emphasis mine): No file is allowed to exist at the target location, also no (dangling) symlink. In this way, if the call succeeds, the file returned is guaranteed to be new. Rule: Anchor on a File Descriptor InsteadA &Path in Rust looks like a value, but remember that to the kernel it’s just a name. That name can point to different things from one syscall to the next. Anchor your operations on a file descriptor instead.create_new() only helps with that when you’re creating a new file. For everything else, open the parent directory once and work relative to that handle.If you act on the same path twice, assume it’s a TOCTOU (Time Of Check To Time Of Use) bug until you’ve proven otherwise.Set Permissions at Creation Time, Not AfterThis is a close relative of TOCTOU. You want a directory with restrictive permissions, so you write something like this.// Create with default permissions fs::create_dir(&path)?; // Fix up permissions fs::set_permissions(&path, Permissions::from_mode(0o700))?;For a brief moment, path exists with the default permissions. Any other user on the system can open() it during that window. Once they have a file descriptor, the later chmod doesn’t take it away from them.Rule: Set Permissions at Creation, Never AfterReach for OpenOptions::mode() and DirBuilderExt::mode() so the file or directory is born with the permissions you want. The kernel will apply your umask on top, so set that explicitly too if you really care.

§3 Human · 16%

String Equality on Paths Is Not the Same as Filesystem IdentityThe original --preserve-root check in chmod was literally this:if recursive && preserve_root && file == Path::new("/") { return Err(PreserveRoot); }That comparison is bypassed by anything that resolves to / but isn’t spelled /. So /../, /./, /usr/.., or a symlink that points to /. Run chmod -R 000 /../ and see it rip right past your check and lock down the whole system.Here’s the fix:fn is_root(file: &Path) -> bool { matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/")) }

if recursive && preserve_root && is_root(file) { return Err(PreserveRoot); }Rule: Resolve Paths Before Comparing Themcanonicalize resolves .., ., and symlinks into a real absolute path. That’s a lot better than string comparison.Oh and if you were wondering about this line:matches!(fs::canonicalize(file), Ok(p) if p == Path::new("/"))I think that’s just a fancy way of saying// First, resolve the path to its canonical form if let Ok(p) = fs::canonicalize(file) { // If that succeeded, check if the canonical path is "/" p == Path::new("/") } else { false }In the specific case of --preserve-root, this works because / has no parent directory, so there’s nothing for an attacker to swap from underneath you. In the more general case of comparing two arbitrary paths for filesystem identity, however, you’d want to open both and compare their (dev, inode) pairs, the way GNU coreutils does. (Think identity, not string equality.)By the way, my favorite bug in this group is CVE-2026-35363:rm . # ❌ rm .. # ❌ rm ./ # ✅ rm ./// # ✅ It refused . and .. but happily accepted ./ and .///, then deleted the current directory while printing Invalid input. 😅Stay in Bytes at Unix BoundariesRust’s String and &str are always UTF-8.

§4 Human · 2%

That’s a great choice in 99% of all cases, but Unix paths, environment variables, arguments, and the inputs flowing through tools like cut, comm, and tr live in the messy world of bytes.Every time a Rust program bridges that gap, it has three options. 🫩 Lossy conversion with from_utf8_lossy silently rewrites invalid bytes to U+FFFD. That’s just fancy data corruption. 🫤 Strict conversion with unwrap or ? crashes or refuses to operate. 😚 Staying in bytes with OsStr or &[u8] is what you should usually do. The audit found bugs in both of the first two categories. Here’s an example.Case Study: comm (CVE-2026-35346)This is the original code, from src/uu/comm/src/comm.rs.// ra, rb are &[u8], raw bytes from the input files. print!("{}", String::from_utf8_lossy(ra)); print!("{delim}{}", String::from_utf8_lossy(rb));GNU comm works on binary files because it just shuffles bytes around. The uutils version replaced anything that wasn’t valid UTF-8 with U+FFFD, which silently corrupted the output.Here’s the fix: stay in bytes.let mut out = BufWriter::new(io::stdout().lock()); out.write_all(ra)?; out.write_all(delim)?; out.write_all(rb)?;print! forces a UTF-8 round-trip through Display. Write::write_all does not. It writes the raw bytes directly to stdout.Rule: Pick the Right Type for the SituationFor Unix-flavored systems code, use Path and PathBuf for filesystem paths, OsString for environment variables, and Vec<u8> or &[u8] for stream contents. It’s tempting to round-trip them through String for easier formatting, but that’s where the corruption creeps in.UTF-8 is a great default for application strings, but it’s absolutely, positively the wrong default for the raw byte stuff Unix tools work with.Treat Every panic! as a Denial of ServiceIn a CLI, every unwrap, every expect, every slice index, every unchecked arithmetic operation, every from_utf8 is a potential denial of service if an attacker can shape the input.

§5 Human · 4%

That’s because a panic! unwinds the stack and aborts the process. If your tool is running in a cron job, a CI pipeline, or a shell script, that means the whole thing just stops working. Even worse, you could find yourself in a crash loop that paralyzes the entire system.A canonical case from the audit was sort --files0-from (CVE-2026-35348). The flag reads a NUL-separated list of filenames from a file, but the parser called expect() on a UTF-8 conversion of each name:// Inside sort.rs, simplified let path = std::str::from_utf8(bytes) .expect("Could not parse string from zero terminated input.");GNU sort treats filenames as raw bytes, the way the kernel does. The uutils version required UTF-8 and aborted the whole process on the first non-UTF-8 path:$ python3 -c "open('list0','wb').write(b'weird\xffname\0')" $ coreutils sort --files0-from=list0 thread 'main' panicked at uu_sort-0.2.2/src/sort.rs:1076:18: Could not parse string from zero terminated input.: Utf8Error { valid_up_to: 5, error_len: Some(1) } note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace(I reproduced this against coreutils 0.2.2 on macOS. The Python one-liner is there because most modern shells refuse to create a non-UTF-8 filename for you.)Your nightly cron job is dead and there goes your weekend.Rule: Turn Bad Input Into Errors, Not PanicsIn code that processes untrusted input, treat every unwrap, expect, indexing, or as cast as a CVE waiting to be filed. Use ?, get, checked_*, try_from, and surface a real error. Push back on the boundary of your application and let the caller deal with the fallout.

§6 Mixed · 42%

A good lint baseline to catch this in CI:[lints.clippy] unwrap_used = "warn" expect_used = "warn" panic = "warn" indexing_slicing = "warn" arithmetic_side_effects = "warn"These are noisy in test code where panicking on bad data is exactly what you want. The cleanest way to scope them to non-test code is to put #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing, clippy::arithmetic_side_effects))] at the top of each crate root, or to gate #[allow(...)] on the individual #[cfg(test)] modules.Propagate Errors, Don’t Discard ThemClosely related to the previous point, a few CVEs come from ignoring or losing error information.chmod -R and chown -R returned the exit code of the last file processed instead of the worst one. So chmod -R 600 /etc/secrets/* could fail on half the files and still exit 0. Your script thinks everything is fine.dd called Result::ok() on its set_len() call to mimic GNU’s behavior on /dev/null. The intent was reasonable, but that same code ran for regular files too, so a full disk silently produced a half-written destination.The reason was that someone wanted to throw away a Result and reached for .ok(), .unwrap_or_default(), or let _ =.Rule: Don’t Throw Away Meaningful Error InformationHere’s a very simple pattern to avoid that:// Don't bail on the first error, but remember the worst one. let mut worst = 0; for file in files { if let Err(e) = chmod_one(file) { worst = worst.max(e.exit_code()); } } process::exit(worst);Also, if you write .ok() to discard a Result, leave a comment that explains why this specific failure is safe to ignore.Match the Original Tool’s Behavior ExactlyA surprising number of these CVEs aren’t “the code does something unsafe” but “the code does something different from GNU, and a shell script somewhere relied on the GNU behavior.”The clearest example is kill -1 (CVE-2026-35369).