File Metadata and Directories

Files are not just data blobs. The kernel stores metadata about every file: its size, owner, permissions, timestamps, and more. This chapter teaches you how to query and modify that metadata, and how to navigate the directory tree from both C and Rust.

The stat() Family

The stat() system call fills a struct stat with information about a file. There are three variants:

FunctionOperates onFollows symlinks?
stat()a path (string)Yes
lstat()a path (string)No
fstat()an open fd (int)N/A
/* stat_demo.c -- query file metadata */
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>
#include <pwd.h>
#include <grp.h>

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: %s <path>\n", argv[0]);
        return 1;
    }

    struct stat st;
    if (stat(argv[1], &st) == -1) {
        perror("stat");
        return 1;
    }

    printf("file:        %s\n", argv[1]);
    printf("inode:       %lu\n", (unsigned long)st.st_ino);
    printf("size:        %ld bytes\n", (long)st.st_size);
    printf("blocks:      %ld (512-byte units)\n", (long)st.st_blocks);
    printf("hard links:  %lu\n", (unsigned long)st.st_nlink);

    struct passwd *pw = getpwuid(st.st_uid);
    struct group  *gr = getgrgid(st.st_gid);
    printf("owner:       %s (uid %d)\n", pw ? pw->pw_name : "?", st.st_uid);
    printf("group:       %s (gid %d)\n", gr ? gr->gr_name : "?", st.st_gid);
    printf("permissions: %o\n", st.st_mode & 07777);

    if (S_ISREG(st.st_mode))       printf("type:        regular file\n");
    else if (S_ISDIR(st.st_mode))  printf("type:        directory\n");
    else if (S_ISLNK(st.st_mode))  printf("type:        symlink\n");
    else if (S_ISCHR(st.st_mode))  printf("type:        char device\n");
    else if (S_ISBLK(st.st_mode))  printf("type:        block device\n");
    else if (S_ISFIFO(st.st_mode)) printf("type:        FIFO/pipe\n");
    else if (S_ISSOCK(st.st_mode)) printf("type:        socket\n");

    char timebuf[64];
    struct tm *tm = localtime(&st.st_mtime);
    strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", tm);
    printf("modified:    %s\n", timebuf);

    return 0;
}
$ gcc -Wall -o stat_demo stat_demo.c && ./stat_demo /etc/passwd
file:        /etc/passwd
inode:       1048594
size:        2773 bytes
blocks:      8 (512-byte units)
hard links:  1
owner:       root (uid 0)
group:       root (gid 0)
permissions: 644
type:        regular file
modified:    2025-01-15 10:22:33

The struct stat Layout

struct stat
+------------------+--------------------------------------------+
| st_dev           | device ID of filesystem                    |
| st_ino           | inode number (unique within filesystem)    |
| st_mode          | file type + permissions (see below)        |
| st_nlink         | number of hard links                       |
| st_uid           | owner user ID                              |
| st_gid           | owner group ID                             |
| st_rdev          | device ID (for char/block devices)         |
| st_size          | file size in bytes                         |
| st_blksize       | optimal I/O block size                     |
| st_blocks        | number of 512-byte blocks allocated        |
| st_atim          | last access time                           |
| st_mtim          | last modification time                     |
| st_ctim          | last status change time                    |
+------------------+--------------------------------------------+

st_mode bit layout (16 bits):
+------+------+------+------+------+------+
| type |setuid|setgid|sticky| user | group| other
| 4bit | 1    | 1    | 1    | rwx  | rwx  | rwx
+------+------+------+------+------+------+

Driver Prep: When you implement a character device driver, the kernel populates some of these fields for you. Your driver's getattr callback can override them. Understanding what each field means is essential.

Checking File Type with Macros

The S_IS* macros decode the file type from st_mode:

if (S_ISREG(st.st_mode))  { /* regular file */ }
if (S_ISDIR(st.st_mode))  { /* directory */ }
if (S_ISLNK(st.st_mode))  { /* symbolic link -- use lstat! */ }
if (S_ISCHR(st.st_mode))  { /* character device */ }
if (S_ISBLK(st.st_mode))  { /* block device */ }
if (S_ISFIFO(st.st_mode)) { /* FIFO (named pipe) */ }
if (S_ISSOCK(st.st_mode)) { /* socket */ }

Caution: stat() follows symbolic links. If you call stat() on a symlink, you get the metadata of the target file. Use lstat() to get the metadata of the symlink itself.

Rust: std::fs::metadata

// metadata_demo.rs -- file metadata in Rust
use std::fs;
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;

fn main() -> std::io::Result<()> {
    let path = "/etc/passwd";
    let meta = fs::metadata(path)?;

    println!("file:        {}", path);
    println!("inode:       {}", meta.ino());
    println!("size:        {} bytes", meta.len());
    println!("blocks:      {}", meta.blocks());
    println!("hard links:  {}", meta.nlink());
    println!("uid:         {}", meta.uid());
    println!("gid:         {}", meta.gid());
    println!("permissions: {:o}", meta.permissions().mode() & 0o7777);

    if meta.is_file()    { println!("type:        regular file"); }
    if meta.is_dir()     { println!("type:        directory"); }
    if meta.is_symlink() { println!("type:        symlink"); }

    if let Ok(modified) = meta.modified() {
        println!("modified:    {:?}", modified);
    }

    Ok(())
}

For symlink metadata (equivalent to lstat), use fs::symlink_metadata():

#![allow(unused)]
fn main() {
let meta = fs::symlink_metadata("/some/symlink")?;
println!("is symlink: {}", meta.is_symlink());
}

Rust Note: MetadataExt is Unix-specific (imported from std::os::unix::fs). The cross-platform Metadata type only exposes len(), is_file(), is_dir(), and timestamps. For inode, uid, gid, and other Unix-specific fields, you need the extension trait.

Changing Permissions and Ownership

/* chmod_demo.c -- change file permissions */
#include <stdio.h>
#include <sys/stat.h>

int main(void)
{
    const char *path = "/tmp/chmod_test.txt";

    FILE *fp = fopen(path, "w");
    if (!fp) { perror("fopen"); return 1; }
    fprintf(fp, "secret data\n");
    fclose(fp);

    if (chmod(path, 0400) == -1) {
        perror("chmod");
        return 1;
    }

    struct stat st;
    stat(path, &st);
    printf("permissions: %o\n", st.st_mode & 07777);

    chmod(path, 0644);  /* restore */
    return 0;
}

For changing ownership, chown(path, uid, gid) works the same way. Only root (or a process with CAP_CHOWN) can change file ownership to another user.

In Rust:

use std::fs;
use std::os::unix::fs::PermissionsExt;

fn main() -> std::io::Result<()> {
    let path = "/tmp/chmod_test_rs.txt";
    fs::write(path, "secret data\n")?;

    let perms = fs::Permissions::from_mode(0o400);
    fs::set_permissions(path, perms)?;

    let meta = fs::metadata(path)?;
    println!("permissions: {:o}", meta.permissions().mode() & 0o7777);

    fs::set_permissions(path, fs::Permissions::from_mode(0o644))?;
    Ok(())
}

Directory Operations in C

Reading a Directory

/* readdir_demo.c -- list directory contents */
#include <stdio.h>
#include <stdlib.h>
#include <dirent.h>
#include <sys/stat.h>
#include <string.h>

int main(int argc, char *argv[])
{
    const char *dirpath = argc > 1 ? argv[1] : ".";

    DIR *dp = opendir(dirpath);
    if (!dp) {
        perror("opendir");
        return 1;
    }

    struct dirent *entry;
    while ((entry = readdir(dp)) != NULL) {
        if (strcmp(entry->d_name, ".") == 0 ||
            strcmp(entry->d_name, "..") == 0)
            continue;

        char fullpath[4096];
        snprintf(fullpath, sizeof(fullpath), "%s/%s", dirpath, entry->d_name);

        struct stat st;
        if (lstat(fullpath, &st) == -1) {
            perror(fullpath);
            continue;
        }

        char type = '-';
        if (S_ISDIR(st.st_mode))       type = 'd';
        else if (S_ISLNK(st.st_mode))  type = 'l';
        else if (S_ISCHR(st.st_mode))  type = 'c';
        else if (S_ISBLK(st.st_mode))  type = 'b';

        printf("%c %8ld %s\n", type, (long)st.st_size, entry->d_name);
    }

    closedir(dp);
    return 0;
}
$ gcc -Wall -o readdir_demo readdir_demo.c && ./readdir_demo /tmp
- 32 fd_demo.txt
- 20 buffered.txt
-  0 chmod_test.txt

Creating and Removing Directories

/* mkdir_rmdir.c -- create and remove directories */
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void)
{
    if (mkdir("/tmp/mydir", 0755) == -1)
        perror("mkdir");

    if (mkdir("/tmp/mydir/sub", 0755) == -1)
        perror("mkdir sub");

    printf("created /tmp/mydir/sub\n");

    /* rmdir only works on empty directories */
    rmdir("/tmp/mydir/sub");
    rmdir("/tmp/mydir");
    printf("removed both directories\n");

    return 0;
}

Caution: rmdir() fails with ENOTEMPTY if the directory is not empty. To remove a directory tree, you must remove its contents first (recursively).

/* unlink_rename.c */
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    FILE *fp = fopen("/tmp/old_name.txt", "w");
    fprintf(fp, "I will be renamed\n");
    fclose(fp);

    fp = fopen("/tmp/to_delete.txt", "w");
    fprintf(fp, "I will be deleted\n");
    fclose(fp);

    /* rename() -- atomic on the same filesystem */
    if (rename("/tmp/old_name.txt", "/tmp/new_name.txt") == -1) {
        perror("rename");
        return 1;
    }
    printf("renamed old_name.txt -> new_name.txt\n");

    /* unlink() -- remove a hard link (deletes file if last link) */
    if (unlink("/tmp/to_delete.txt") == -1) {
        perror("unlink");
        return 1;
    }
    printf("deleted to_delete.txt\n");

    unlink("/tmp/new_name.txt");
    return 0;
}

Directory Operations in Rust

// readdir_demo.rs -- list directory contents
use std::fs;

fn main() -> std::io::Result<()> {
    let dirpath = std::env::args().nth(1).unwrap_or_else(|| ".".to_string());

    for entry in fs::read_dir(&dirpath)? {
        let entry = entry?;
        let meta = entry.metadata()?;
        let file_type = entry.file_type()?;

        let type_char = if file_type.is_dir() { 'd' }
            else if file_type.is_symlink() { 'l' }
            else { '-' };

        println!("{} {:>8} {}", type_char, meta.len(),
                 entry.file_name().to_string_lossy());
    }

    Ok(())
}

Creating, Removing, Renaming

// dir_ops.rs -- mkdir, rmdir, rename, remove
use std::fs;

fn main() -> std::io::Result<()> {
    fs::create_dir_all("/tmp/rustdir/sub")?;
    println!("created /tmp/rustdir/sub");

    fs::write("/tmp/rustdir/sub/hello.txt", "hello\n")?;

    fs::rename("/tmp/rustdir/sub/hello.txt",
               "/tmp/rustdir/sub/world.txt")?;
    println!("renamed hello.txt -> world.txt");

    fs::remove_file("/tmp/rustdir/sub/world.txt")?;
    println!("removed world.txt");

    fs::remove_dir_all("/tmp/rustdir")?;
    println!("removed /tmp/rustdir and all contents");

    Ok(())
}

Rust Note: fs::create_dir_all is like mkdir -p -- it creates parent directories as needed. fs::remove_dir_all is like rm -rf -- it removes everything recursively. In C you must walk the tree yourself.

Hard link:
  name_a  ──>  inode 12345  <──  name_b
  (both names point to the same inode; same data blocks)

Symbolic link:
  symlink  ──>  "path/to/target"  (just stores a path string)
      |
      +-- readlink() returns "path/to/target"
      +-- stat() follows to the target
      +-- lstat() returns info about the symlink itself
/* links_demo.c -- create hard and symbolic links */
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>

int main(void)
{
    FILE *fp = fopen("/tmp/original.txt", "w");
    fprintf(fp, "original content\n");
    fclose(fp);

    link("/tmp/original.txt", "/tmp/hardlink.txt");
    symlink("/tmp/original.txt", "/tmp/symlink.txt");

    struct stat st;
    stat("/tmp/original.txt", &st);
    printf("original inode: %lu, nlink: %lu\n",
           (unsigned long)st.st_ino, (unsigned long)st.st_nlink);

    stat("/tmp/hardlink.txt", &st);
    printf("hardlink inode: %lu (same!)\n", (unsigned long)st.st_ino);

    lstat("/tmp/symlink.txt", &st);
    printf("symlink  inode: %lu (different)\n", (unsigned long)st.st_ino);

    char target[256];
    ssize_t n = readlink("/tmp/symlink.txt", target, sizeof(target) - 1);
    if (n != -1) {
        target[n] = '\0';
        printf("symlink target: %s\n", target);
    }

    unlink("/tmp/hardlink.txt");
    unlink("/tmp/symlink.txt");
    unlink("/tmp/original.txt");
    return 0;
}
$ gcc -Wall -o links_demo links_demo.c && ./links_demo
original inode: 2359301, nlink: 2
hardlink inode: 2359301 (same!)
symlink  inode: 2359447 (different)
symlink target: /tmp/original.txt

In Rust:

// links_demo.rs
use std::fs;
use std::os::unix::fs as unix_fs;
use std::os::unix::fs::MetadataExt;

fn main() -> std::io::Result<()> {
    fs::write("/tmp/original_rs.txt", "original content\n")?;

    fs::hard_link("/tmp/original_rs.txt", "/tmp/hardlink_rs.txt")?;
    unix_fs::symlink("/tmp/original_rs.txt", "/tmp/symlink_rs.txt")?;

    let orig = fs::metadata("/tmp/original_rs.txt")?;
    let hard = fs::metadata("/tmp/hardlink_rs.txt")?;
    let sym  = fs::symlink_metadata("/tmp/symlink_rs.txt")?;

    println!("original inode: {}, nlink: {}", orig.ino(), orig.nlink());
    println!("hardlink inode: {} (same!)", hard.ino());
    println!("symlink  inode: {} (different)", sym.ino());

    let target = fs::read_link("/tmp/symlink_rs.txt")?;
    println!("symlink target: {}", target.display());

    fs::remove_file("/tmp/hardlink_rs.txt")?;
    fs::remove_file("/tmp/symlink_rs.txt")?;
    fs::remove_file("/tmp/original_rs.txt")?;
    Ok(())
}

Working with Paths in Rust

Rust provides Path and PathBuf for safe path manipulation:

// path_demo.rs
use std::path::{Path, PathBuf};

fn main() {
    let p = Path::new("/home/user/documents/report.txt");

    println!("file name:   {:?}", p.file_name());
    println!("stem:        {:?}", p.file_stem());
    println!("extension:   {:?}", p.extension());
    println!("parent:      {:?}", p.parent());
    println!("is absolute: {}", p.is_absolute());

    let mut pb = PathBuf::from("/home/user");
    pb.push("documents");
    pb.push("report.txt");
    println!("built path:  {}", pb.display());

    let full = Path::new("/var/log").join("syslog");
    println!("joined:      {}", full.display());
}
$ rustc path_demo.rs && ./path_demo
file name:   Some("report.txt")
stem:        Some("report")
extension:   Some("txt")
parent:      Some("/home/user/documents")
is absolute: true
built path:  /home/user/documents/report.txt
joined:      /var/log/syslog

Try It: Write a program (C or Rust) that recursively walks a directory tree, printing each file's path and size. In C, you will need a recursive function that calls opendir/readdir/stat. In Rust, consider writing a recursive function or use the walkdir crate.

Quick Knowledge Check

  1. What is the difference between stat() and lstat() when called on a symbolic link?

  2. What does st_nlink equal for a regular file with no extra hard links?

  3. Why does rmdir() fail on a non-empty directory, and what must you do to remove a full directory tree in C?

Common Pitfalls

  • Using stat() on symlinks when you meant lstat(). You silently get the target's metadata.

  • Buffer overflow in path construction. In C, building paths with sprintf without length checks is a classic vulnerability. Use snprintf.

  • TOCTOU races. Checking a file's existence with stat() and then opening it is a race condition. Another process can change the file between your check and your open. Use O_CREAT | O_EXCL for atomic creation.

  • Forgetting closedir(). Leaks a file descriptor just like forgetting close().

  • Assuming d_type in struct dirent. Not all filesystems populate d_type. Always fall back to stat() if d_type == DT_UNKNOWN.

  • Hard-linking across filesystems. Hard links only work within a single filesystem. Use symbolic links for cross-filesystem references.