Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  • Path and PathBuf are the borrowed type and the owned type. They correspond exactly to str and String.
  • The builder methods are join, push, pop, set_file_name, and set_extension. The anatomy accessors are parent, file_name, file_stem, and extension.
  • components() and ancestors() give the parsed, structural view.
  • canonicalize resolves a path through the filesystem, and std::path::absolute resolves 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

ConceptKey point
Path / PathBufBorrowed view and owned buffer, the same pair as str and String
Function signaturesTake &Path (or impl AsRef<Path>), return PathBuf
join / pushjoin borrows and builds a new path. push mutates in place. Each inserts the separator for you
Absolute-join problembase.join("/abs") discards the base. Validate untrusted input
file_stem / extensionThey divide the name at the last dot. A leading dot never starts an extension
set_extensionIt 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_withFull components, never substrings
canonicalizeFilesystem-backed. The path must exist. It resolves symlinks
macOS temp problem/var → /private/var: assert canonical tails, compare canonical forms
std::path::absoluteLexical absolute path. It works on paths that do not exist yet
display()Lossy printing for paths that are possibly not UTF-8

Code Examples

FileDescription
09_09_path_pathbuf_basics.rsBorrowed and owned paths, join/push/pop, set_file_name/set_extension, the AsRef<Path> pattern
09_10_path_components.rsAnatomy accessors, components(), ancestors(), component-wise comparisons, MAIN_SEPARATOR
09_11_canonicalize_absolute.rscanonicalize and path::absolute, the macOS problem with the symlinked temp directory, display, try_exists