9.2 · Directory Operations and Metadata
Domain 9 — Filesystem Operations Duration: ~15 minutes Library components:
std::fs,std::fs::DirEntry,std::fs::Metadata,std::fs::Permissions,std::fs::FileType,std::fs::FileTimes
Introduction
Filesystem code is less simple when it uses directories:
- A creation can fail in the middle of a chain of missing directories.
- A listing arrives in unspecified order.
- Metadata has fields that some platforms do not have.
This tutorial gives you the mental model that you need to write correct portable code:
- The four directory functions (
create_dir,create_dir_all,remove_dir,remove_dir_all), and the exactErrorKindthat each one returns on failure. fs::read_dirand theDirEntryiterator, with a manual recursive walk.- The
Metadatastruct: type checks, sizes, the three timestamps, andPermissions. - Why timestamp code must assert sanity properties and never exact values, and why you must handle the
Resultthatcreated()returns. - How to set timestamps by path with
fs::set_timesandfs::set_times_nofollow(stabilized in 1.99).
Creating Directories
fs::create_dir creates exactly one level. The parent must exist, and the directory itself must not exist. Each of the two failures has an exact error kind:
// tmp: the path of an existing scratch directory. src: the path `tmp.join("src")`.
fs::create_dir(&src)?; // ok: the parent exists
let err = fs::create_dir(&src).expect_err("src already exists");
assert_eq!(err.kind(), ErrorKind::AlreadyExists); // a second call is an error
let deep = tmp.join("target/debug/deps"); // `target` does not exist
let err = fs::create_dir(&deep).expect_err("parents missing");
assert_eq!(err.kind(), ErrorKind::NotFound); // it does not create the parents
fs::create_dir_all is the equivalent of mkdir -p. It creates each missing ancestor. It also succeeds if the directory already exists, and this is important. Because the call is idempotent, you can call it unconditionally at startup:
// `deep` is the path target/debug/deps from the previous snippet.
fs::create_dir_all(&deep)?; // creates target/, target/debug/, target/debug/deps
fs::create_dir_all(&deep)?; // second call: also Ok, because the directory exists
Removing Directories
Removal is not symmetric with creation, and the difference is a safety feature. fs::remove_dir refuses a directory that is not empty, with ErrorKind::DirectoryNotEmpty:
// `src` is the directory from the first snippet. It is empty at this point.
fs::write(src.join("main.rs"), "fn main() {}\n")?; // puts one file in src/
let err = fs::remove_dir(&src).expect_err("src has a file in it");
assert_eq!(err.kind(), ErrorKind::DirectoryNotEmpty);
fs::remove_dir_all is the rm -rf of the standard library. It removes the contents first and then the directory. It does not ask for confirmation, and it does not use a trash can. Remember two things:
create_dir_allaccepts a directory that exists, butremove_dir_alldoes not accept a target that is missing. The removal of a path that does not exist returnsNotFound.- On Windows, the removal can fail on a read-only file on some filesystems (FAT32, for example). Restore the write permission before you remove a scratch tree (see the section "Permissions and fs::set_permissions").
09_05_create_remove_dirs.rs prints:
create_dir src/: ok
create_dir src/ again: AlreadyExists
create_dir target/debug/deps: NotFound
create_dir_all (twice): ok — idempotent
remove_dir on non-empty src/: DirectoryNotEmpty
remove_dir on empty deps/: ok
remove_dir_all src/: ok (file inside deleted too)
remove_dir_all src/ again: NotFound
All assertions passed.
read_dir and the DirEntry Iterator
fs::read_dir returns an iterator of io::Result<DirEntry>. Each entry can fail independently (a file may disappear during the listing), and thus the item type is a Result. The iterator never includes . and .., which POSIX readdir does include.
The most important fact about read_dir is that the iteration order is unspecified. The order differs between platforms, between filesystems, and even between runs. Code that asserts or displays a listing must first collect the entries and sort them:
// root: &Path, a directory that holds notes.txt, readme.md, and src/.
let mut names = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?; // each item is an io::Result<DirEntry>
// file_name() returns an OsString. This line converts it to a String.
names.push(entry.file_name().to_string_lossy().into_owned());
}
names.sort(); // without the sort, the comparison can fail on some runs
assert_eq!(names, ["notes.txt", "readme.md", "src"]);
DirEntry gives you three methods:
path()returns the root joined with the name.file_name()returns only the final component.file_type()returns the type of the entry. On most platforms, it is cheaper than a fullmetadata()call, because the OS returns the type together with the name.
A recursive walk needs only an explicit stack. An explicit stack has no recursion-depth limit, and the walkdir crate uses the same approach internally:
// root: &Path. found: Vec<PathBuf>, empty at the start.
let mut stack = vec![root.to_path_buf()]; // the directories that the walk must read
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
stack.push(entry.path()); // the loop reads this subdirectory later
} else if entry.path().extension() == Some(OsStr::new("rs")) {
found.push(entry.path()); // keeps each file with the extension "rs"
}
}
}
found.sort(); // gives the result a stable order
09_06_read_dir_walk.rs prints:
top level (sorted): ["notes.txt", "readme.md", "src"]
files: 2, dirs: 1
.rs files found by the walk:
src/lib.rs # (Windows prints backslashes)
src/main.rs
src/util/helpers.rs
All assertions passed.
The Metadata Struct
fs::metadata(path) calls stat. It returns all the data that the OS has about the path, in one snapshot. fs::symlink_metadata is the variant that does not follow a symlink. metadata follows a symlink and describes the target. symlink_metadata describes the link itself (tutorial 9.4 gives the details of links).
Figure: Metadata and its satellite types
// file: PathBuf, the path of `data.log`, which holds the 10 bytes "0123456789".
let meta = fs::metadata(&file)?; // one snapshot: type, size, timestamps, permissions
assert!(meta.is_file() && !meta.is_dir() && !meta.is_symlink());
assert_eq!(meta.len(), 10); // for a file, len() is the exact byte count
Be careful with one case: the len() of a directory is platform-defined (block sizes, entry counts, or other data that the filesystem stores). It is never the "total size of contents". To compute the size of a directory, walk its contents.
Timestamps: Sanity, Not Exactness
Metadata gives three timestamps, each as an io::Result<SystemTime>. The Result has a purpose:
modified(): available on all the major platforms. This is the timestamp that you can rely on.accessed(): production systems frequently disable it or update it in batches (noatime/relatimemounts). Use it only as information.created(): may legitimately fail withErrorKind::Unsupported, because many filesystems do not record the creation time. Portable code usesmatchon the result and does not unwrap it:
// meta: the Metadata of `data.log`. modified: SystemTime, from `meta.modified()?`.
match meta.created() {
Ok(created) => assert!(created <= modified), // the write came after the creation
// The kind is ErrorKind::Unsupported on a filesystem that has no creation time.
Err(e) => println!("created() unsupported on this filesystem: {:?}", e.kind()),
}
Never assert exact timestamp values. Assert properties:
- ordering (
created <= modified) - approximate recency (
modified().elapsed()? < 1 hour) - monotonicity after writes
For monotonicity, use >= and not >. Filesystems round timestamps (some to whole seconds), so two writes that are close in time can have the same timestamp:
// Before this line, the example appends one byte to `file`. `modified` is the earlier timestamp.
let modified_after = fs::metadata(&file)?.modified()?;
assert!(modified_after >= modified); // >= because the two timestamps can be equal
09_07_metadata_timestamps.rs prints:
data.log: is_file=true, len=10 bytes
scratch dir: is_dir=true
modified 0s ago (sanity: < 1 hour) # (the number of seconds can vary)
created() supported here; created <= modified holds # (varies with the filesystem)
after append: len=11, modified moved forward (or equal): true
link.txt → target.txt: # (Unix only: Windows prints a skip line)
metadata(): is_file=true, len=7
symlink_metadata(): is_symlink=true
All assertions passed.
The rule "never assert exact values" has one exception: a timestamp that your program set. Since Rust 1.99, fs::set_times sets the access time and the modification time of a path. File::set_times (stable since 1.75) needs an open file handle. The path function also works for a directory. The FileTimes builder holds the values, and a timestamp that you do not set stays as it is. Archive extractors and sync tools use this function to restore a recorded modification time:
use std::fs::{self, FileTimes};
use std::time::{Duration, SystemTime};
// 2001-09-09 01:46:40 UTC, as seconds after the Unix epoch.
let archived = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
let times = FileTimes::new().set_accessed(archived).set_modified(archived);
fs::set_times(&report, times)?; // `report` is a path, not an open File
// Compare whole seconds: each filesystem stores a different precision.
let modified = fs::metadata(&report)?.modified()?;
assert_eq!(modified.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(), 1_000_000_000);
fs::set_times follows a symbolic link and changes the target. fs::set_times_nofollow changes the link itself, in the same way that symlink_metadata reads the link itself. 09_15_set_times.rs prints:
set_times: report.txt mtime = 1000000000 (seconds after the epoch)
set_times: archive/ mtime = 1000000000
set_times on a missing path: NotFound
set_times_nofollow: link mtime = 1600000000, target mtime = 1000000000 # (Unix only)
All assertions passed.
Permissions and fs::set_permissions
Permissions is a snapshot value, not a live handle. A mutation changes only your copy in memory. The disk does not change until fs::set_permissions writes the value:
// report: PathBuf, the path of a writable file.
let mut perms = fs::metadata(&report)?.permissions(); // a copy of the permissions
perms.set_readonly(true); // changes only the copy in memory
assert!(!fs::metadata(&report)?.permissions().readonly()); // the file on disk is still writable
fs::set_permissions(&report, perms)?; // writes the copy to the disk
assert!(fs::metadata(&report)?.permissions().readonly()); // now the file is read-only
The portable API is small by design: readonly() and set_readonly. It has only what Windows and Unix have in common. Two platform notes:
- The
set_readonly(false)hazard: on Unix, the call sets the write bits for owner, group, and others, so the file becomes world-writable. Clippy reports the call (permissions_set_readonly_false). On Unix, use explicit bits withPermissionsExt::from_mode(0o644). On Windows, only the read-only attribute exists, soset_readonly(false)is the correct call. chmodsemantics:fs::set_permissionsapplies exactly the bits that you give it, and the umask has no effect. This is different fromOpenOptions::modeat creation (tutorial 9.1).
09_08_set_permissions.rs prints:
set_readonly(true) alone: disk still writable
after fs::set_permissions: readonly = true
write to read-only file: PermissionDenied # (varies: the root user can write)
restored writable: readonly = false
deploy.sh mode: 0o755 (rwxr-xr-x) — executable bit set # (Unix only)
All assertions passed.
Summary
| Concept | Key point |
|---|---|
create_dir | One level. AlreadyExists on a second call, NotFound when a parent is missing. |
create_dir_all | The call creates the full chain. It is idempotent, so you can call it unconditionally. |
remove_dir | Empty directories only. DirectoryNotEmpty for all others. |
remove_dir_all | Recursive, as rm -rf. The call returns NotFound for a missing target. |
read_dir | Iterator of io::Result<DirEntry>. The order is unspecified: sort before you assert. |
DirEntry::file_type() | Cheaper than metadata(). Sufficient for the decisions of a walk. |
Metadata | One stat snapshot: type, len, timestamps, permissions |
metadata vs symlink_metadata | metadata follows the symlink. symlink_metadata describes the link itself. |
len() on directories | A platform-defined value. Never the "size of contents". |
modified / accessed / created | Reliable / informational / possibly Unsupported |
| Timestamp assertions | Sanity properties only: ordering, recency, >= monotonicity |
fs::set_times / set_times_nofollow (1.99) | The functions set the access time and the modification time by path. The nofollow form changes a symlink itself. |
Permissions | A snapshot value. Changes apply only through fs::set_permissions. |
set_readonly(false) | World-writable on Unix. Use PermissionsExt mode bits there. |
Code Examples
| File | Description |
|---|---|
09_05_create_remove_dirs.rs | The four directory functions and the exact ErrorKind of each failure mode |
09_06_read_dir_walk.rs | read_dir, DirEntry, a sort for the unspecified order, a recursive walk with a stack |
09_07_metadata_timestamps.rs | Metadata fields, sanity assertions for timestamps, a created() call that can fail, symlink_metadata |
09_08_set_permissions.rs | Permissions as a snapshot, a read-only round trip, Unix mode bits with PermissionsExt |
09_15_set_times.rs | fs::set_times / fs::set_times_nofollow (1.99) with FileTimes: timestamps by path for files, directories, and symlinks |