Path

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

#182 Jun 2026

182. Path::with_extension — Swap a File Extension Without Slicing Strings

You have report.txt and want report.md. Reaching for replace(".txt", ".md") or a rfind('.')? Stop — Path::with_extension returns a fresh PathBuf with the extension swapped, and it gets every edge case right.

The string-slicing trap

The naïve fix looks reasonable until you read it carefully:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn change_ext_bad(name: &str, ext: &str) -> String {
    match name.rfind('.') {
        Some(i) => format!("{}.{}", &name[..i], ext),
        None    => format!("{}.{}", name, ext),
    }
}

assert_eq!(change_ext_bad("report.txt", "md"), "report.md");
// But...
assert_eq!(change_ext_bad("./.bashrc", "bak"), "./.bak"); // ate the dotfile name

That second case is the bug: ./.bashrc has no extension — the leading dot is part of the name. Manual rfind('.') doesn’t know that.

The fix: Path::with_extension

1
2
3
4
5
6
use std::path::{Path, PathBuf};

let p = Path::new("reports/q1.txt");
let renamed: PathBuf = p.with_extension("md");

assert_eq!(renamed, PathBuf::from("reports/q1.md"));

It returns a new PathBuf — original path untouched — and stays in OsStr land the whole way through, so non-UTF-8 paths survive intact.

Dotfiles are handled the way you’d want:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new(".bashrc").with_extension("bak"),
           PathBuf::from(".bashrc.bak"));

No extension to start? It adds one instead of failing:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("Makefile").with_extension("bak"),
           PathBuf::from("Makefile.bak"));

Pass "" to strip the extension

The same method, with an empty string, removes the extension entirely — no separate without_extension API needed:

1
2
3
4
use std::path::{Path, PathBuf};

let src = Path::new("build/main.o");
assert_eq!(src.with_extension(""), PathBuf::from("build/main"));

Common pattern in build scripts: derive an output path from an input path.

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

fn object_for(src: &Path) -> PathBuf {
    src.with_extension("o")
}

assert_eq!(object_for(Path::new("src/main.rs")),
           PathBuf::from("src/main.o"));
assert_eq!(object_for(Path::new("src/lib.rs")),
           PathBuf::from("src/lib.o"));

Only the last extension changes

with_extension replaces from the last dot — same rule as file_stem. For archive.tar.gz, that means only .gz gets swapped:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("archive.tar.gz").with_extension("zst"),
           PathBuf::from("archive.tar.zst"));

That’s almost always what you want for compression tools. If you need to strip the whole .tar.gz and start over, call with_extension("") twice — or reach for file_prefix (see bite 116).

set_extension if you already own the PathBuf

The mutating sibling lives on PathBuf and avoids the allocation when you already own the path:

1
2
3
4
5
use std::path::PathBuf;

let mut p = PathBuf::from("notes/draft.md");
p.set_extension("html");
assert_eq!(p, PathBuf::from("notes/draft.html"));

Returns booltrue if the extension was set, false if the path had no file name to attach one to. Most callers ignore it.

Reach for with_extension (or set_extension) any time you’d otherwise write a rfind('.') or a replace(".old", ".new"). It’s been stable since Rust 1.0 — there’s no excuse left.

#175 Jun 2026

175. PathBuf::push — When an Absolute Argument Wipes Your Base Path

base.push(user_input) looks like string concatenation for paths. It isn’t — if user_input is absolute, the original base is gone.

The footgun

PathBuf::push reads almost like += for paths. Most of the time, it behaves that way:

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

let mut p = PathBuf::from("/home/alice");
p.push("docs");
p.push("notes.txt");
assert_eq!(p, PathBuf::from("/home/alice/docs/notes.txt"));

But the moment the pushed component is absolute, push throws the existing buffer away and starts over:

1
2
3
4
5
use std::path::PathBuf;

let mut p = PathBuf::from("/home/alice");
p.push("/etc/passwd");
assert_eq!(p, PathBuf::from("/etc/passwd"));

That’s not a bug. The docs spell it out: “if path is absolute, it replaces the current path.” It mirrors how cd /etc/passwd works in a shell. The catch is that when one half of push is user input, the cd-like behavior turns into a path-traversal vector.

Why this bites

The most common shape of the bug:

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

fn user_file(home: &str, requested: &str) -> PathBuf {
    let mut p = PathBuf::from(home);
    p.push(requested);
    p
}

assert_eq!(
    user_file("/srv/users/alice", "avatar.png"),
    PathBuf::from("/srv/users/alice/avatar.png"),
);

// An absolute `requested` silently escapes the sandbox.
assert_eq!(
    user_file("/srv/users/alice", "/etc/passwd"),
    PathBuf::from("/etc/passwd"),
);

No panic, no error, no warning. The function just hands back a path that points somewhere else entirely.

The fix

Reject absolute components before joining. Path::is_absolute and Path::has_root are the two checks you need:

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

fn safe_join(base: &Path, requested: &str) -> Option<PathBuf> {
    let segment = Path::new(requested);
    if segment.is_absolute() || segment.has_root() {
        return None;
    }
    Some(base.join(segment))
}

assert_eq!(
    safe_join(Path::new("/srv/users/alice"), "avatar.png"),
    Some(PathBuf::from("/srv/users/alice/avatar.png")),
);
assert_eq!(safe_join(Path::new("/srv/users/alice"), "/etc/passwd"), None);

has_root matters on Windows too — \windows\system32 has a root but no drive prefix, and push will replace the non-prefix portion of your buffer with it. is_absolute alone misses that case on Windows.

For full sandbox enforcement you also want to canonicalize and check the result is still under base.. components can still escape — but stopping the absolute-path case is the cheap first line of defence.

Takeaway

PathBuf::push is not string concatenation. Treat any component you didn’t write yourself as suspect and gate it through is_absolute / has_root before letting it near your buffer.

142. Path::absolute — Make a Path Absolute Without Touching the Filesystem

Need an absolute path for a log line, an error message, or a “files will land here” preview — but the file might not exist yet? fs::canonicalize will refuse. std::path::absolute (stable since Rust 1.79) gives you the absolute form without ever opening the disk.

The canonicalize trap

The instinctive choice for “turn this into a full path” is fs::canonicalize. It works — until it doesn’t:

1
2
3
4
use std::fs;

let p = fs::canonicalize("does_not_exist.toml");
assert!(p.is_err()); // canonicalize requires the path to exist

It also resolves symlinks and walks every .. component against the real directory tree. That’s the right behaviour for finding a file. It’s wrong for printing one back to the user before you’ve written it.

path::absolute does the syntactic thing

std::path::absolute joins a relative path with the current working directory and normalises the result. No syscalls beyond looking up the CWD; the file doesn’t have to exist:

1
2
3
4
5
use std::path::absolute;

let p = absolute("config/app.toml").unwrap();
assert!(p.is_absolute());
// e.g. "/work/config/app.toml" — without ever opening anything

If the path is already absolute it’s left alone (modulo platform-specific normalisation). .. components are resolved syntactically, without consulting the filesystem for what each directory really is.

Useful for nicely-formatted output

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::path::{absolute, PathBuf};

fn describe(relative: &str) -> String {
    let abs: PathBuf = absolute(relative).unwrap();
    format!("writing to {}", abs.display())
}

let msg = describe("logs/today.log");
assert!(msg.contains("logs/today.log"));
assert!(msg.starts_with("writing to "));

When you’re echoing the user’s choices back to them, or building helpful error messages, this is usually what you want — the path they meant, not whatever the filesystem turned it into.

When to reach for it

Use path::absolute for log lines, config previews, default-location calculations, or any “this is where it will go” message about a file that might not exist yet. Stick with fs::canonicalize when you actually want to follow symlinks and prove the file exists — that’s its job.

Stabilised in Rust 1.79 (June 2024).

#116 May 2026

116. Path::file_prefix — Get the Real Stem of archive.tar.gz

Path::file_stem strips the last extension, so archive.tar.gz comes back as archive.tar. That’s almost never what you want for double-extension files. file_prefix strips from the first dot instead — archive, finally.

The classic confusion. You ask for the “stem” of a tarball and get something with .tar still glued on:

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

let p = Path::new("backups/archive.tar.gz");

assert_eq!(p.file_stem(),   Some("archive.tar".as_ref()));
assert_eq!(p.extension(),   Some("gz".as_ref()));

file_stem takes the file name and drops everything from the last . onwards. For a single extension that’s fine. For .tar.gz, .min.js, .d.ts, .spec.ts, you end up doing the second strip yourself:

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

fn real_stem_old(p: &Path) -> Option<&str> {
    let stem = p.file_stem()?.to_str()?;
    Some(stem.split('.').next().unwrap_or(stem))
}

assert_eq!(real_stem_old(Path::new("archive.tar.gz")), Some("archive"));
assert_eq!(real_stem_old(Path::new("bundle.min.js")),  Some("bundle"));

Works, but you’ve left OsStr land just to do a string split, and you’ve quietly made the function lossy on non-UTF-8 paths.

Rust 1.91 stabilised Path::file_prefix. It returns the file name up to the first . — staying in OsStr the whole time:

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

assert_eq!(Path::new("archive.tar.gz").file_prefix(), Some("archive".as_ref()));
assert_eq!(Path::new("bundle.min.js").file_prefix(),  Some("bundle".as_ref()));
assert_eq!(Path::new("notes.md").file_prefix(),       Some("notes".as_ref()));
assert_eq!(Path::new("README").file_prefix(),         Some("README".as_ref()));

Leading dots on dotfiles are kept — exactly like file_stem already does — so you don’t accidentally turn .bashrc into an empty string:

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

assert_eq!(Path::new(".bashrc").file_prefix(),     Some(".bashrc".as_ref()));
assert_eq!(Path::new(".config.toml").file_prefix(), Some(".config".as_ref()));

Pair it with file_stem when you want both halves of a multi-extension name in one place:

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

let p = Path::new("logs/app.2026-05-03.log.gz");
let prefix = p.file_prefix().and_then(|s| s.to_str()).unwrap_or("");
let stem   = p.file_stem().and_then(|s| s.to_str()).unwrap_or("");

assert_eq!(prefix, "app");                     // the real name
assert_eq!(stem,   "app.2026-05-03.log");      // everything except the final ext

Reach for file_prefix whenever a filename has more than one dot and you want the part a human would call “the name”.