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.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 exact ErrorKind that each one returns on failure.
  • fs::read_dir and the DirEntry iterator, with a manual recursive walk.
  • The Metadata struct: type checks, sizes, the three timestamps, and Permissions.
  • Why timestamp code must assert sanity properties and never exact values, and why you must handle the Result that created() returns.
  • How to set timestamps by path with fs::set_times and fs::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_all accepts a directory that exists, but remove_dir_all does not accept a target that is missing. The removal of a path that does not exist returns NotFound.
  • 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 full metadata() 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/relatime mounts). Use it only as information.
  • created(): may legitimately fail with ErrorKind::Unsupported, because many filesystems do not record the creation time. Portable code uses match on 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 with PermissionsExt::from_mode(0o644). On Windows, only the read-only attribute exists, so set_readonly(false) is the correct call.
  • chmod semantics: fs::set_permissions applies exactly the bits that you give it, and the umask has no effect. This is different from OpenOptions::mode at 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

ConceptKey point
create_dirOne level. AlreadyExists on a second call, NotFound when a parent is missing.
create_dir_allThe call creates the full chain. It is idempotent, so you can call it unconditionally.
remove_dirEmpty directories only. DirectoryNotEmpty for all others.
remove_dir_allRecursive, as rm -rf. The call returns NotFound for a missing target.
read_dirIterator 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.
MetadataOne stat snapshot: type, len, timestamps, permissions
metadata vs symlink_metadatametadata follows the symlink. symlink_metadata describes the link itself.
len() on directoriesA platform-defined value. Never the "size of contents".
modified / accessed / createdReliable / informational / possibly Unsupported
Timestamp assertionsSanity 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.
PermissionsA snapshot value. Changes apply only through fs::set_permissions.
set_readonly(false)World-writable on Unix. Use PermissionsExt mode bits there.

Code Examples

FileDescription
09_05_create_remove_dirs.rsThe four directory functions and the exact ErrorKind of each failure mode
09_06_read_dir_walk.rsread_dir, DirEntry, a sort for the unspecified order, a recursive walk with a stack
09_07_metadata_timestamps.rsMetadata fields, sanity assertions for timestamps, a created() call that can fail, symlink_metadata
09_08_set_permissions.rsPermissions as a snapshot, a read-only round trip, Unix mode bits with PermissionsExt
09_15_set_times.rsfs::set_times / fs::set_times_nofollow (1.99) with FileTimes: timestamps by path for files, directories, and symlinks