9.3 · Path and PathBuf: Cross-Platform Path Manipulation
Domain 9 — Filesystem Operations Duration: ~15 minutes Library components:
std::path::Path,std::path::PathBuf,std::path::Component,std::path::Prefix,std::path::MAIN_SEPARATOR
Introduction
A path looks like a string, but it is not a string. A path has platform-specific separators, and it may contain non-UTF-8 bytes (it wraps OsStr, see tutorial 3.4). Rust compares paths component by component, not character by character. std::path gives you two types and a parser that handle these properties:
PathandPathBufare the borrowed type and the owned type. They correspond exactly tostrandString.- The builder methods are
join,push,pop,set_file_name, andset_extension. The anatomy accessors areparent,file_name,file_stem, andextension. components()andancestors()give the parsed, structural view.canonicalizeresolves a path through the filesystem, andstd::path::absoluteresolves it lexically.display()prints a path.
Almost all of these operations are lexical: they change the path in memory and never access the disk. The exceptions are canonicalize, exists(), and try_exists(), which ask the filesystem. The platform-specific problems occur with canonicalize (slide 6).
Path vs PathBuf: Borrowed vs Owned
Path is an unsized view. PathBuf owns a heap buffer and is mutable. The relationship is the same as for str and String, and that includes the conversion methods:
Figure: The Path/PathBuf ownership pair
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
let borrowed: &Path = Path::new("src/main.rs"); // a view of the literal, no allocation
let owned: PathBuf = PathBuf::from("src/main.rs"); // owns a copy on the heap
assert_eq!(borrowed, owned.as_path()); // as_path borrows the PathBuf as &Path
// PathBuf derefs to Path, so each Path method works on a PathBuf.
assert_eq!(owned.extension(), Some(OsStr::new("rs")));
The API design rule for &str and String (tutorial 3.1) applies here too. A function should take &Path, and it should return PathBuf when it builds a new path. To give callers the most flexibility, accept impl AsRef<Path>. The free functions of std::fs declare their path parameters with the same AsRef<Path> bound. Thus you can pass &str, String, &Path, or PathBuf to fs::read:
// `classify` is a helper of the example. It takes &Path and returns a label for the extension.
fn describe(path: impl AsRef<Path>) -> String {
let path = path.as_ref(); // convert to &Path one time, then use only &Path
format!("{:<12} → {}", path.display(), classify(path))
}
// One function accepts four argument types, and the caller does no conversion.
describe("src/lib.rs"); // &str
describe(String::from("Cargo.toml")); // String
describe(Path::new("README.md")); // &Path
describe(PathBuf::from("build.log")); // PathBuf
Building Paths: join, push, pop
join borrows the path and returns a new PathBuf. push mutates the PathBuf in place. The same division exists between + and push_str on strings. The two methods insert the platform separator for you:
let manifest = Path::new("workspace").join("Cargo.toml"); // new PathBuf: workspace/Cargo.toml
let mut nested = PathBuf::from("reports");
nested.push("2026"); // reports/2026
nested.push("q3.md"); // reports/2026/q3.md
assert!(nested.pop()); // removes q3.md and returns true: reports/2026
Each program that handles paths must take one behavior into account: a join of an absolute path replaces the full base.
let hijacked = Path::new("safe/base").join("/etc/passwd");
assert_eq!(hijacked, Path::new("/etc/passwd")); // join discards "safe/base" and gives no error
// push with an absolute path replaces the full path in the same way.
The documentation of join specifies this behavior. If a program joins user input to a sandbox directory, this behavior becomes a path-traversal vulnerability. Clippy's join_absolute_paths lint reports the cases where the argument is a literal. Validate untrusted input before you join it.
09_09_path_pathbuf_basics.rs prints the output below. The next slide explains the set_extension line:
Path::new / PathBuf::from agree: src/main.rs
built by push: reports/2026/q3.md
join("/etc/passwd") hijacks the base: /etc/passwd
data.tar + set_extension("gz") = backup/data.gz
src/lib.rs → Rust source
Cargo.toml → manifest
README.md → docs
build.log → other
All assertions passed.
The Anatomy Accessors
Each path divides into named parts. Each accessor returns an Option, because not every path has every part:
Figure: Anatomy of a path
file_stem and extension divide the file name at the last dot only. Thus app-v2.4.1.tar.gz has the stem app-v2.4.1.tar and the extension gz, not tar.gz. A leading dot never starts an extension, so the full name of a dotfile is its stem:
let dotfile = Path::new(".gitignore");
assert_eq!(dotfile.file_stem(), Some(OsStr::new(".gitignore"))); // the full name is the stem
assert_eq!(dotfile.extension(), None); // there is no extension
The methods that mutate a path use the same last-dot rule. set_extension replaces all the text after the last dot, which can be an unexpected result:
let mut archive = PathBuf::from("backup/data.tar");
archive.set_extension("gz"); // replaces "tar", does not append
assert_eq!(archive, Path::new("backup/data.gz")); // not data.tar.gz
// To get data.tar.gz, call set_extension("tar.gz") on backup/data.
set_file_name replaces the full final component. with_extension and with_file_name are the variants that do not mutate: each returns a new PathBuf.
components() and ancestors()
components() gives the parsed view: an iterator of Component values. The variants are Prefix (Windows only), RootDir, CurDir, ParentDir, and Normal. The parser normalizes the path a little. It removes repeated separators and each interior ., but it keeps a leading . and each ..:
let messy = Path::new("./src//utils/./mod.rs");
// messy.components() yields 4 values:
// [CurDir, Normal("src"), Normal("utils"), Normal("mod.rs")]
// The parser removed the second "/" of "//" and the interior ".".
The parser keeps .. because it cannot resolve .. lexically. If a is a symlink to /x/y, then a/../b names /x/b, not the sibling b. Only canonicalize may remove .., because it reads the real filesystem. This one design decision explains most of the difference between the lexical APIs and the filesystem-backed APIs.
ancestors() yields the path and then each parent, to the top. It is the standard tool to go up the tree until you find Cargo.toml:
let chain: Vec<&Path> = Path::new("/var/log/syslog").ancestors().collect();
// chain is ["/var/log/syslog", "/var/log", "/var", "/"]
Comparisons also use components. starts_with and ends_with match full components, never substrings. strip_prefix is the inverse of join:
let config = Path::new("/etc/nginx/nginx.conf");
assert!(config.starts_with("/etc"));
assert!(!config.starts_with("/et")); // "/et" is not a full component
assert!(!config.ends_with("conf")); // "conf" is the extension, not a full component
// strip_prefix removes the base and returns the relative remainder as &Path.
assert_eq!(config.strip_prefix("/etc").unwrap(), Path::new("nginx/nginx.conf"));
On Windows, an absolute path starts with a Prefix component (C:, a UNC share) before RootDir. Unix does not have this concept. For this reason, the answer to "is this path absolute?" depends on the platform.
MAIN_SEPARATOR is / on Unix and \ on Windows, but you rarely need it. join and push insert the separator for you, and the parser accepts / on each platform.
09_10_path_components.rs prints:
path: builds/2026-07/app-v2.4.1.tar.gz
parent: builds/2026-07
file_name: app-v2.4.1.tar.gz
file_stem: app-v2.4.1.tar
extension: gz
.gitignore: stem=.gitignore, extension=None
"./src//utils/./mod.rs" parses to 4 components
ancestors of /var/log/syslog: 4 entries
starts_with("/etc")=true, starts_with("/et")=false (component-wise)
MAIN_SEPARATOR on this platform: '/' # (Unix. Windows prints '\\')
(no Prefix components on Unix — absolute paths start at RootDir '/') # (Unix. Windows prints "prefix: drive C")
All assertions passed.
canonicalize, std::path::absolute, and display
canonicalize asks the filesystem to resolve ., .., and symlinks. It returns an absolute path to the real file. Thus the path must exist: if it does not, the call fails with NotFound. canonicalize is the correct way to test if two lexically different paths name the same file:
// direct: PathBuf, <tmp>/data/metrics.csv (the file exists)
// messy: PathBuf, <tmp>/data/../data/./metrics.csv (a different spelling of the same file)
// The code is in a function that returns io::Result, so `?` is permitted.
assert_ne!(direct, messy); // the path values are different
assert_eq!(direct.canonicalize()?, messy.canonicalize()?); // the two paths name one file
One problem occurs in practice on macOS. There, env::temp_dir() is below /var, and /var is a symlink to /private/var. Thus a canonicalized temp path does not start with the original temp_dir() string. Code that compares raw prefixes fails there. The portable rules are:
- Assert the tail of a canonical path (
canon.ends_with("data/metrics.csv")), never its prefix. - To compare two paths, canonicalize both sides first.
std::path::absolute is the lexical alternative. It uses the current directory to make a path absolute, but it resolves no symlinks and accesses no inodes. Thus it works on paths that do not exist yet, such as output locations and files that you will create.
Path does not implement Display, because a path may not be valid UTF-8. To print a path, call display(): it returns an adapter that always works but is lossy. When you need the strict answer, call to_str(), which returns None for a non-UTF-8 name.
exists() converts each I/O error to false. try_exists() returns the error to the caller. For a dangling symlink, try_exists() returns Ok(false) (tutorial 9.4).
09_11_canonicalize_absolute.rs prints the output below. In the first line, the program itself prints the … in place of the temp directory:
lexical form: …/data/../data/./metrics.csv
canonical ends with data/metrics.csv: true
lexically different, canonically equal: true
temp root canonicalized to a DIFFERENT path (symlinked ancestor, e.g. /var) # (macOS. With no symlinked ancestor, the line says "already canonical")
canonicalize on a missing path: NotFound
path::absolute on the same path: ok (works without touching disk)
display(): /var/folders/yk/rj3_nqx94_35jbstjmxqgzl80000gn/T/rust-tut-09-11-49609/data/metrics.csv # (varies: temp directory and process id)
exists: file=true, ghost=false
All assertions passed.
Summary
| Concept | Key point |
|---|---|
Path / PathBuf | Borrowed view and owned buffer, the same pair as str and String |
| Function signatures | Take &Path (or impl AsRef<Path>), return PathBuf |
join / push | join borrows and builds a new path. push mutates in place. Each inserts the separator for you |
| Absolute-join problem | base.join("/abs") discards the base. Validate untrusted input |
file_stem / extension | They divide the name at the last dot. A leading dot never starts an extension |
set_extension | It replaces the text after the last dot: data.tar + gz → data.gz |
components() | Parsed view. It removes // and each interior ., and it keeps .. (symlink semantics) |
ancestors() | The path and each parent, for a search upward to the nearest marker file |
starts_with / ends_with | Full components, never substrings |
canonicalize | Filesystem-backed. The path must exist. It resolves symlinks |
| macOS temp problem | /var → /private/var: assert canonical tails, compare canonical forms |
std::path::absolute | Lexical absolute path. It works on paths that do not exist yet |
display() | Lossy printing for paths that are possibly not UTF-8 |
Code Examples
| File | Description |
|---|---|
09_09_path_pathbuf_basics.rs | Borrowed and owned paths, join/push/pop, set_file_name/set_extension, the AsRef<Path> pattern |
09_10_path_components.rs | Anatomy accessors, components(), ancestors(), component-wise comparisons, MAIN_SEPARATOR |
09_11_canonicalize_absolute.rs | canonicalize and path::absolute, the macOS problem with the symlinked temp directory, display, try_exists |