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

#261 Jul 2026

261. String::retain — Delete Characters In Place, No New Allocation

Stripping characters with replace("-", "") builds a brand-new String just to throw characters away. retain deletes them in the buffer you already own.

The trap

This morning’s bite (260) covered replacen — but both replace and replacen always return a fresh String, even when the “replacement” is deleting. Same story with the iterator route:

1
2
3
4
5
6
7
let phone = String::from("+49 (0)30 901820");

// both of these allocate a whole new String
let a = phone.replace(|c: char| !c.is_ascii_digit(), "");
let b: String = phone.chars().filter(char::is_ascii_digit).collect();
assert_eq!(a, "49030901820");
assert_eq!(b, a);

If you’re cleaning strings in a loop, that’s one allocation per string, per pass — for data you already had in a perfectly good buffer.

The fix

String::retain keeps every char the closure approves and shifts the rest out, in place, in one O(n) pass:

1
2
3
4
5
6
7
let mut phone = String::from("+49 (0)30 901820");
let cap = phone.capacity();

phone.retain(|c| c.is_ascii_digit());

assert_eq!(phone, "49030901820");
assert_eq!(phone.capacity(), cap); // same buffer

No new allocation, and the capacity stays put — ready for push_str later. It reads as intent, too: “keep digits” instead of “replace non-digits with nothing”.

One caveat

The closure sees chars in order, exactly once, and retain keeps what returns true — it’s a keep-list, not a kill-list. To delete matches, negate:

1
2
3
let mut s = String::from("no_more_underscores");
s.retain(|c| c != '_');
assert_eq!(s, "nomoreunderscores");

Vec<T> and VecDeque<T> have the same method, so the pattern transfers. When you need to substitute text, replace/replacen earn their allocation — but when you’re only deleting, retain does it where the string already lives.

#260 Jul 2026

260. str::replacen — Replace the First N Matches, Not Every Single One

replace is all-or-nothing: it rewrites every occurrence, whether you wanted that or not. replacen lets you say how many.

The trap

str::replace has no off switch — it replaces every match in the string. The moment your pattern appears somewhere you didn’t expect, it happily rewrites that too:

1
2
3
4
5
6
7
let line = "user=admin role=admin";

// demote the user, keep the role... oops
assert_eq!(
    line.replace("admin", "guest"),
    "user=guest role=guest"
);

The usual workaround is find + manual slicing — index math, an allocation, and an edge case when the pattern is missing.

The fix

replacen takes a third argument: the maximum number of replacements, counted from the left. Everything after the Nth match is left alone:

1
2
3
4
assert_eq!(
    line.replacen("admin", "guest", 1),
    "user=guest role=admin"
);

Fewer matches than n is fine — it just replaces what’s there. And like replace, the pattern can be a char, a &str, or a closure over char:

1
2
3
let csv = "a-b-c-d";
assert_eq!(csv.replacen('-', "+", 2), "a+b+c-d");
assert_eq!(csv.replacen('-', "+", 0), "a-b-c-d"); // n = 0: copy, untouched

One caveat

Counting is strictly left-to-right — there’s no rreplacen for “just the last one”. For that, reach for rfind and slice, or rsplit_once if you’re splitting anyway.

Both replace and replacen return a fresh String and leave the original untouched. If you only need to check the first match, find is cheaper — but when you need “replace the first one and stop”, replacen(pat, to, 1) says exactly that.

#259 Jul 2026

259. str::lines — Split Into Lines Without Dragging \r Along

split('\n') works fine — until a Windows-saved file hands you lines that all end in an invisible \r. lines() was built for exactly this.

The trap

Same story as bite 258: split takes your separator literally. A file saved on Windows uses \r\n line endings, so every “line” keeps a carriage return — and the trailing newline produces a bonus empty string:

1
2
3
4
5
6
7
let text = "alpha\r\nbeta\r\ngamma\r\n";

let naive: Vec<&str> = text.split('\n').collect();
assert_eq!(
    naive,
    ["alpha\r", "beta\r", "gamma\r", ""]
);

That stray \r is invisible in most debug output, so it surfaces as "gamma" != "gamma" mysteries: failed comparisons, HashMap misses, parse errors on the last field.

The fix

lines() splits on \n and strips a trailing \r if one is there — so the same code handles Unix and Windows files:

1
2
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines, ["alpha", "beta", "gamma"]);

No trailing empty string either — like split_terminator (bite 233), the final newline is treated as a terminator, not a separator.

One caveat

Only \r\n and \n count as line endings. A lone \r (classic Mac OS, some protocol payloads) does not split:

1
2
3
let old_mac = "alpha\rbeta";
let lines: Vec<&str> = old_mac.lines().collect();
assert_eq!(lines, ["alpha\rbeta"]);

And a \r in the middle of a line stays untouched — only one directly before the \n is stripped.

Reading a file? BufRead::lines() gives you the same semantics over owned Strings. Either way: for “give me the lines”, it’s lines() every time — save split('\n') for when you truly mean raw bytes-between-newlines.

#258 Jul 2026

258. split_whitespace — Split on Runs, Not on Every Single Space

split(' ') hands you empty strings for every doubled space — and silently ignores tabs. split_whitespace is what you actually meant.

The trap

User input is messy: leading spaces, double spaces, a stray tab. split(' ') takes all of that literally:

1
2
3
4
5
6
7
let line = "  alpha\tbeta   gamma ";

let naive: Vec<&str> = line.split(' ').collect();
assert_eq!(
    naive,
    ["", "", "alpha\tbeta", "", "", "gamma", ""]
);

Two bugs in one line: every consecutive-space pair produces an empty string, and "alpha\tbeta" sails through as a single “word” because a tab isn’t a space.

The fix

split_whitespace splits on runs of any whitespace and never yields empty strings:

1
2
let words: Vec<&str> = line.split_whitespace().collect();
assert_eq!(words, ["alpha", "beta", "gamma"]);

Leading and trailing whitespace disappear too — no trim() needed first.

Unicode-aware, with an ASCII fast path

“Whitespace” here means the Unicode White_Space property, so a non-breaking space (\u{00A0}) splits words just like a regular one:

1
2
3
let fancy = "alpha\u{00A0}beta";
let words: Vec<&str> = fancy.split_whitespace().collect();
assert_eq!(words, ["alpha", "beta"]);

If your input is guaranteed ASCII (log files, protocol lines), split_ascii_whitespace does the same thing with a cheaper per-byte check — same no-empty-strings guarantee:

1
2
3
let words: Vec<&str> =
    " 42  7\t9 ".split_ascii_whitespace().collect();
assert_eq!(words, ["42", "7", "9"]);

Keep split(' ') for formats where empty fields are meaningful (CSV-like, fixed positions). For “give me the words”, it’s split_whitespace every time.

#257 Jul 2026

257. VecDeque::rotate_left — Where the Ring Buffer Finally Pays Rent

slice::rotate_left touches every element, every time. The same call on a VecDeque moves at most half of them — often far fewer.

Rotation on a slice is O(len)

Bite 133 covered slice::rotate_left: in-place, no allocation, but every rotation is O(len) — all elements physically move through memory.

This morning’s bite 256 showed the cost of VecDeque’s ring buffer: no single slice to hand out. Rotation is where that layout pays you back.

The deque version

VecDeque has its own rotate_left / rotate_right, and the ring buffer turns rotation into pointer arithmetic plus a short move:

1
2
3
4
5
6
7
8
9
use std::collections::VecDeque;

let mut buf: VecDeque<i32> = (0..10).collect();

buf.rotate_left(3);
assert_eq!(buf, [3, 4, 5, 6, 7, 8, 9, 0, 1, 2]);

buf.rotate_right(3);
assert_eq!(buf, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);

It’s the loop you’d write by hand — pop one end, push the other — but done as a bulk move:

1
2
3
4
5
6
// rotate_left(3), spelled out:
for _ in 0..3 {
    let x = buf.pop_front().unwrap();
    buf.push_back(x);
}
assert_eq!(buf, [3, 4, 5, 6, 7, 8, 9, 0, 1, 2]);

Because elements may wrap around the allocation’s end, “moving” the front to the back mostly means shifting the head index. The documented bound is O(min(mid, len − mid)) time and no extra space — rotate_left(3) on a million-element deque moves 3 elements, not a million. The same call on a slice moves all of them.

Where it shines: round-robin

A scheduler that cycles through tasks is one rotate_left(1) per turn — O(1):

1
2
3
4
5
let mut tasks: VecDeque<&str> =
    ["fetch", "parse", "render"].into();

tasks.rotate_left(1);
assert_eq!(tasks, ["parse", "render", "fetch"]);

Both methods panic if mid > len(), so a full-cycle rotate_left(len) is legal (and a no-op) but len + 1 is not — use k % len if k can exceed the length.

If you’re rotating a Vec in a hot loop, that’s the signal to switch containers: VecDeque::from(vec) is O(1), and every rotation after that is the cheap kind.