Pitfall

#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.

#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.