#271 Jul 2026

271. fs::rename — Replace a File Atomically, Never Expose a Half-Write

fs::write truncates the file before writing. Crash halfway — or get read halfway — and the world sees a torn file. fs::rename is the atomic swap that fixes it.

The obvious way to save state overwrites in place:

1
2
// Truncates state.json to 0 bytes, then writes.
fs::write("state.json", &json)?;

Between the truncate and the final byte, state.json is incomplete. A concurrent reader gets garbage; a crash or power loss leaves it that way permanently. For a config file, a cache, or anything another process watches, that window is a real bug.

The fix is the classic write-then-rename dance:

1
2
fs::write("state.json.tmp", &json)?;   // build the full file aside
fs::rename("state.json.tmp", "state.json")?; // atomic swap

On every major platform, rename over an existing file is atomic: any observer sees either the old complete file or the new complete file, never a mix and never a missing file. If the process dies before the rename, the old file is untouched and only a .tmp straggler is left behind.

Three details worth knowing:

  • Keep the temp file in the same directory. rename can’t cross filesystems — a temp file in /tmp and a target on another mount fails with an error (ErrorKind::CrossesDevices). Same directory guarantees same filesystem, and keeps the swap atomic.
  • Durability needs one more step. Atomic ≠ flushed. If you need the data to survive power loss, open the temp file yourself and call sync_all() before renaming, instead of using fs::write.
  • Unique temp names matter under concurrency. Two processes writing state.json.tmp will trample each other. Give each writer its own name (PID, random suffix) — and create it with File::create_new (bite 169) so collisions fail loudly.

Same moral as try_exists in bite 270: don’t check-then-act on the filesystem — make the filesystem do the transition in one atomic step.

#270 Jul 2026

270. Path::try_exists — exists() Can't Tell "Missing" From "Couldn't Check"

path.exists() returns false for a file that’s missing — and for a file that’s right there behind a directory you can’t read. try_exists() finally separates the two.

Path::exists() is implemented as “did fs::metadata succeed?”. Any failure — permission denied on a parent directory, an I/O error, an interrupted syscall — collapses into false:

1
2
3
4
5
6
let cfg = Path::new("/etc/app/config.toml");

if !cfg.exists() {
    // Missing? Or unreadable? No way to know.
    write_default_config(cfg)?;
}

If the app lacks permission to look inside /etc/app, this code concludes the config is absent and happily tries to overwrite it — or silently skips loading settings that exist. The error didn’t go away; it was converted into wrong control flow.

Path::try_exists() (stable since 1.63) returns io::Result<bool>, so “no” and “don’t know” are different answers:

1
2
3
4
5
match cfg.try_exists() {
    Ok(true)  => load_config(cfg)?,
    Ok(false) => write_default_config(cfg)?,
    Err(e)    => return Err(e.into()), // surface it
}

Ok(false) now means the path is definitely absent — the lookup succeeded and found nothing. Everything else is an Err you can log, retry, or propagate with ?.

Two details worth knowing:

  • Broken symlinks report Ok(false)try_exists() follows symlinks, so a link pointing at nothing counts as “the target doesn’t exist”. Use symlink_metadata if the link itself is what you care about.
  • TOCTOU still applies — any exists-then-act sequence can race with other processes. For “create only if absent”, skip the check entirely and use OpenOptions::new().create_new(true) (bite 169), which makes the filesystem decide atomically.

Same theme as file_type() in bite 269: the std methods returning plain bool are quietly eating errors, and each has a Result sibling that doesn’t.

#269 Jul 2026

269. DirEntry::file_type — Stop Paying a stat() Per Entry

Filtering read_dir entries with path.is_file() costs a full metadata lookup per entry — and quietly reports false when the check fails. DirEntry::file_type() is free on most platforms and tells you when it couldn’t answer.

This morning’s bite 268 collected read_dir entries into paths. The next thing most code does is filter them, and the obvious way has two hidden problems:

1
2
3
4
5
6
for entry in fs::read_dir("logs")? {
    let path = entry?.path();
    if path.is_file() {          // stat() per entry
        process(&path);
    }
}

First, Path::is_file() asks the filesystem for full metadata — an extra syscall for every entry, on top of the directory read that already happened. Second, it returns plain bool: if the metadata lookup fails (permissions, a file deleted mid-loop), you get false, indistinguishable from “this is a directory”. Errors vanish.

The entry you already have knows its own type:

1
2
3
4
5
6
for entry in fs::read_dir("logs")? {
    let entry = entry?;
    if entry.file_type()?.is_file() {
        process(&entry.path());
    }
}

Two things improved:

  • no extra syscall — on Linux, readdir returns each entry’s type alongside its name (d_type), and Windows directory entries carry file attributes. file_type() just hands you what the directory read already produced. (A few filesystems don’t fill d_type in; std falls back to a metadata call only then.)
  • errors surfacefile_type() returns io::Result<FileType>, so a failed lookup is a ?-able error instead of a silent false

One behavioral difference to know: file_type() does not follow symlinks — a symlink to a file reports is_symlink(), not is_file(), while Path::is_file() traverses the link and reports on the target. If following symlinks is what you actually want, that’s the one case for fs::metadata(entry.path()) — deliberately, not by accident.

#268 Jul 2026

268. fs::read_dir — The Filesystem Doesn't Sort for You

fs::read_dir looks like ls, but ls sorts its output and read_dir doesn’t. You get entries in whatever order the filesystem feels like — and that order changes between machines.

The docs say it plainly: “The order in which this iterator returns entries is platform and filesystem dependent.” On your dev box the entries may happen to come back alphabetical; on ext4 with hashed directories, or in CI, they won’t. Code like this works until it doesn’t:

1
2
3
4
// Process migrations in order... on SOME machines
for entry in fs::read_dir("migrations")? {
    apply(&entry?.path()); // 002 may run before 001
}

The fix is to collect and sort — PathBuf is Ord, so it’s two lines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::fs;

let mut paths: Vec<_> = fs::read_dir("migrations")?
    .map(|res| res.map(|e| e.path()))
    .collect::<Result<Vec<_>, _>>()?;
paths.sort();

for p in &paths {
    apply(p); // 001, then 002, on every machine
}

Two details worth noticing along the way:

  • each item the iterator yields is a Result — reading an individual entry can fail even after opening the directory succeeded. Collecting into Result<Vec<_>, _> surfaces the first error instead of hiding it in the loop
  • DirEntry::path() hands back the entry joined to the base you passed in (migrations/001.sql, not 001.sql) — ready to open, no join needed

Sorting PathBufs compares component-wise, which is what you want for nested listings. Just remember it’s lexicographic: 10.sql sorts before 2.sql — zero-pad your file names or sort by a parsed key.

#267 Jul 2026

267. Path::file_name — rsplit('/') Hands You Empty Strings and '..'

Grabbing the last path segment with rsplit('/') returns "" for logs/ and ".." for logs/.. — two values that were never file names. Path::file_name reasons in components and returns None instead.

The string version looks harmless:

1
2
3
4
5
6
7
8
let name = "logs/app.log".rsplit('/').next();
assert_eq!(name, Some("app.log")); // so far so good

let name = "logs/".rsplit('/').next();
assert_eq!(name, Some("")); // empty "file name"

let name = "logs/..".rsplit('/').next();
assert_eq!(name, Some("..")); // that's a traversal, not a file

Feed either of those into a join or a delete-by-name and you have a bug — or worse. Path::file_name works on components, not characters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::ffi::OsStr;
use std::path::Path;

let p = Path::new("logs/app.log");
assert_eq!(p.file_name(), Some(OsStr::new("app.log")));

// Trailing slash? Still the same last component
let dir = Path::new("logs/");
assert_eq!(dir.file_name(), Some(OsStr::new("logs")));

// '..' is not a name — you get None, not a footgun
assert_eq!(Path::new("logs/..").file_name(), None);
assert_eq!(Path::new("/").file_name(), None);

The rules it applies:

  • the last component wins, so a trailing separator changes nothing: logs/logs
  • a path ending in .. has no final name to give you → None
  • the root itself has no name → None

Like Path::extension from bite 266, the return type is Option<&OsStr>: the “there is no file name here” case arrives as a None the compiler makes you handle, instead of an empty string or a .. sneaking into your filesystem calls.

#266 Jul 2026

266. Path::extension — Splitting on '.' Breaks on .gitignore

Grabbing a file extension with split('.') works right up until it meets .gitignore — which suddenly has the “extension” gitignore. Path::extension knows the actual rules.

The tempting one-liner:

1
2
3
4
5
let ext = "backup.tar.gz".split('.').last();
assert_eq!(ext, Some("gz")); // fine so far...

let ext = ".gitignore".split('.').last();
assert_eq!(ext, Some("gitignore")); // that's the *name*!

A dotfile’s leading dot is part of its name, not an extension separator. Your homemade splitter doesn’t know that. Path::extension and Path::file_stem do:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::ffi::OsStr;
use std::path::Path;

let p = Path::new("backup.tar.gz");
assert_eq!(p.extension(), Some(OsStr::new("gz")));
assert_eq!(p.file_stem(), Some(OsStr::new("backup.tar")));

// The dot IS the name — no extension here
let hidden = Path::new(".gitignore");
assert_eq!(hidden.extension(), None);
assert_eq!(hidden.file_stem(), Some(OsStr::new(".gitignore")));

let plain = Path::new("Makefile");
assert_eq!(plain.extension(), None);

The rules extension applies:

  • everything after the last dot, so backup.tar.gzgz (and the stem keeps backup.tar)
  • a leading dot with no other dot means dotfile, not extension: .gitignoreNone
  • no dot at all → None, instead of handing you the whole filename back

Both methods return Option<&OsStr>, so the “there is no extension” case is a None you must handle — not an empty string or a full filename silently pretending to be one.

#265 Jul 2026

265. Path::strip_prefix — Compare Paths by Component, Not by Character

Checking path_str.starts_with("/srv/uploads") says yes to /srv/uploads-old too. Paths need component-wise comparison — and Path has it built in.

The string trap

This morning’s bite 264 showed join silently discarding your base. Here’s the sibling mistake: checking containment with string prefixes.

1
2
// looks right, isn't
assert!("/srv/uploads-old/x.png".starts_with("/srv/uploads"));

/srv/uploads-old is a completely different directory, but as a string it starts with /srv/uploads. Any allowlist built on string prefixes has this hole.

The component-aware way

Path::starts_with compares whole components, so a partial directory name never matches:

1
2
3
4
5
6
use std::path::Path;

let base = Path::new("/srv/uploads");

assert!(Path::new("/srv/uploads/cat.png").starts_with(base));
assert!(!Path::new("/srv/uploads-old/cat.png").starts_with(base));

And when you also want the remainder — for display, for re-joining elsewhere — strip_prefix returns it as a relative path instead of a boolean:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::path::Path;

let base = Path::new("/srv/uploads");
let full = Path::new("/srv/uploads/img/cat.png");

let rel = full.strip_prefix(base).unwrap();
assert_eq!(rel, Path::new("img/cat.png"));

// non-matching base → Err, not a mangled path
assert!(Path::new("/etc/passwd").strip_prefix(base).is_err());

Still lexical

Like everything in std::path, this is pure component comparison — nothing touches the filesystem. That means .. is just another component:

1
2
3
4
use std::path::Path;

// true! ".." is not resolved
assert!(Path::new("/srv/uploads/../etc").starts_with("/srv/uploads"));

So starts_with alone is not a traversal guard. Validate components before joining (see bite 264), or canonicalize both sides first — then starts_with on the resolved paths is the real containment check.

#264 Jul 2026

264. Path::join — One Absolute Path and Your Base Directory Is Gone

base.join(user_input) looks like it appends. But if the input starts with /, join silently throws your base away and returns the input alone.

The trap

Path::join (and PathBuf::push) have a documented surprise: if the argument is an absolute path, it replaces the whole path instead of appending.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::path::Path;

let base = Path::new("/srv/uploads");

// what you expect
let a = base.join("cat.png");
assert_eq!(a, Path::new("/srv/uploads/cat.png"));

// absolute input: base is gone
let b = base.join("/etc/passwd");
assert_eq!(b, Path::new("/etc/passwd"));

No panic, no warning — just a path that escaped your directory. If the joined segment comes from user input (an upload filename, a URL fragment, a config value), this is a path-traversal hole that .. filters won’t catch, because there’s no .. in sight.

The fix

Validate before you join: the input must be relative, and every component must be a normal segment — no .., no root, no prefix.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::path::{Component, Path, PathBuf};

fn safe_join(base: &Path, user: &str) -> Option<PathBuf> {
    let p = Path::new(user);
    let ok = p.is_relative()
        && p.components()
            .all(|c| matches!(c, Component::Normal(_)));
    ok.then(|| base.join(p))
}

let base = Path::new("/srv/uploads");

assert!(safe_join(base, "/etc/passwd").is_none());
assert!(safe_join(base, "../secret").is_none());
assert_eq!(
    safe_join(base, "img/cat.png"),
    Some(PathBuf::from("/srv/uploads/img/cat.png"))
);

The components() check rejects .. traversal in the same pass, so both escape routes are closed with one predicate.

One caveat

This blocks the lexical escapes, not symlinks — if the tree may contain attacker-created links, canonicalize and verify the result still starts with your base. And on Windows the same replacement happens with drive letters and prefixes (base.join("C:\\evil")), so the is_relative() check is doing real work on every platform.

#263 Jul 2026

263. String::replace_range — Swap a Span In Place, No Rebuild

Swapping one piece of a String by slicing and re-format!-ing rebuilds the whole thing. replace_range splices the new text straight into the buffer you already own.

The trap

You need to change one segment in the middle of a String. The slice-and-rebuild reflex kicks in:

1
2
3
4
5
let mut path = String::from("/api/v1/users");

// new allocation, three copies, old String dropped
path = format!("{}{}{}", &path[..5], "v2", &path[7..]);
assert_eq!(path, "/api/v2/users");

A fresh allocation and three copies to change two bytes. Bite 262’s insert_str covers inserting at a point — but here you want to replace a span.

The fix

String::replace_range takes a byte range and splices the replacement in, in place:

1
2
3
4
let mut path = String::from("/api/v1/users");

path.replace_range(5..7, "v2");
assert_eq!(path, "/api/v2/users");

The replacement doesn’t have to match the range’s length — the tail shifts to fit:

1
2
3
4
let mut greet = String::from("Hello, world!");

greet.replace_range(7..12, "rustbites");
assert_eq!(greet, "Hello, rustbites!");

And any range form works. An empty replacement deletes the span — no drain iterator to throw away:

1
2
3
4
let mut line = String::from("DEBUG: cache miss");

line.replace_range(..7, "");
assert_eq!(line, "cache miss");

Together with insert_str (bite 262) you get the full in-place toolkit: insert at a point, replace a span, delete a range — all without touching the allocator when capacity allows.

One caveat

Same rules as insert_str: the range is in bytes and both ends must land on char boundaries, or it panics — mid-emoji is a runtime error, not a compile error. And because the tail may shift, it’s O(n) per call: fine for a targeted splice, wrong as the workhorse of a text editor’s inner loop.

#262 Jul 2026

262. String::insert_str — Prepend or Splice Text Without Rebuilding the String

Adding a prefix with format!("{prefix}{s}") builds a brand-new String and throws the old one away. insert_str splices the text into the buffer you already have.

The trap

You have a String and need to stick something in front of it — a log level, a scheme, a marker. The reflex is format!:

1
2
3
4
5
6
let mut msg = String::from("connection lost");

// allocates a second String, copies both halves,
// drops the original
msg = format!("[ERROR] {msg}");
assert_eq!(msg, "[ERROR] connection lost");

That’s a full new allocation and two copies just to add eight bytes at the front. push_str only helps at the end — there’s no push_front for strings.

The fix

String::insert_str shifts the existing bytes over and copies the new text in, reusing the allocation when capacity allows:

1
2
3
4
let mut msg = String::from("connection lost");

msg.insert_str(0, "[ERROR] ");
assert_eq!(msg, "[ERROR] connection lost");

And it’s not just for prepending — the index can be anywhere, which makes splicing into the middle a one-liner:

1
2
3
4
5
6
let mut name = String::from("report_final.txt");

if let Some(dot) = name.rfind('.') {
    name.insert_str(dot, "_v2");
}
assert_eq!(name, "report_final_v2.txt");

For a single character there’s the sibling String::insert(idx, char).

One caveat

The index is a byte offset and must land on a char boundary — mid-emoji it panics, same rule as slicing. And since the tail gets shifted each call, insert_str is O(n): perfect for the occasional splice, wrong for building a string front-to-back in a loop. If you’re prepending repeatedly, collect the pieces and join them once instead.