File Handling · intermediate · ~10 min

File permissions and stat

- Read a file's metadata (size, type, timestamps, and permission bits) with `stat` without opening the file for I/O. - Decode `st_mode` using the type macros (`S_ISREG`, `S_ISDIR`, `S_ISLNK`) and the permission masks (`S_IRUSR`, `S_IWGRP`, `S_IWOTH`, ...). - Explain the difference between `stat` and `lstat` and pick the right one for symlinks. - Validate a file (type, ownership, mode) *before* opening it, and handle the case where the file does not exist. - Print POSIX permissions in the familiar `rwxr-xr-x` form by testing individual bits.

Overview

In the prerequisite lesson fopen and the stdio model you learned to open a file and read or write its contents through a FILE * stream. But a file is more than its bytes. The operating system also keeps a small record of facts about each file: how big it is, what kind of thing it is (an ordinary file, a directory, a symbolic link), when it was last modified, who owns it, and who is allowed to read, write, or run it. This record is called the file's metadata, and on POSIX systems (Linux, macOS, the BSDs) you read it with the stat family of functions.

Reading metadata is different from reading contents. fopen says "give me a stream so I can pull bytes out." stat says "tell me about this file — I may not even want its bytes." That distinction matters: you often want to check a file before committing to opening it. Is it actually a regular file and not a directory? Is it world-writable, which might mean someone tampered with it? How large is it, so you can size a buffer or refuse a 4 GB upload? stat answers all of these by filling a struct stat you hand it.

The permission information lives inside one field, st_mode, which packs two things together: the file type and the nine classic Unix permission bits (read/write/execute for the owner, the group, and everyone else). You decode st_mode with helper macros, never by guessing numbers. Once you can read it, the same vocabulary (rwx for user, group, other) is exactly what you see in ls -l and what you set with chmod.

This lesson builds directly on stdio file handling and prepares you for working with binary files next, where knowing a file's exact size and type up front becomes essential.

Why it matters

Almost every real program that touches the filesystem needs metadata, not just contents:

  • Robustness. Opening a directory as if it were a text file, or trying to read a file that does not exist, leads to confusing errors. A quick stat check turns a crash or garbage output into a clean, explainable failure.
  • Performance and resource sizing. Knowing st_size lets you allocate a buffer once instead of growing it repeatedly, decide whether to memory-map a file, or reject an oversized input early.
  • Security and integrity. Permission bits reveal whether a config file is world-writable (anyone on the machine could have edited it) or whether a script is unexpectedly executable. Tools like backup utilities, package managers, and intrusion-detection systems all compare metadata over time to detect tampering.
  • Correct behavior with links. Build systems, archivers (tar), and file managers must decide whether to follow a symlink or record the link itself. Getting stat vs lstat wrong here causes data loss or copying the wrong thing.

In short, stat is how a program looks before it leaps — it inspects the world before changing or trusting it.

Core concepts

1. Metadata vs. contents

Definition. Metadata is data describing a file rather than the file's payload. The kernel stores it in an on-disk structure called an inode; stat copies the relevant parts of that inode into a struct stat in your program.

Why it is separate. Reading metadata does not require read permission on the file's contents — it requires permission to look up the path (execute permission on the containing directories). That is why a program can know a file's size without being allowed to read its bytes.

Your program            Kernel
-----------             ------
stat("a.txt", &st)  --> looks up inode for a.txt
                        copies size, mode, times, owner...
     st (struct stat) <-- filled in

When to use / not use. Use stat to inspect before acting. Do not use it as a substitute for handling open errors: a file can change between your stat and your fopen (this race is called TOCTOU — time-of-check to time-of-use). Always still check the result of fopen.

Pitfall. Forgetting that stat can fail (file missing, no directory permission). It returns 0 on success and -1 on error, setting errno. Treating an uninitialized struct stat as valid gives garbage.

Knowledge check: In your own words, why can a program sometimes read a file's size but not its contents?

2. The struct stat and its key fields

Definition. struct stat (declared in <sys/stat.h>) is a structure the kernel fills with metadata. You do not create files with it; you read facts into it.

The fields you will use most:

Field Type Meaning
st_size off_t Size in bytes (for regular files)
st_mode mode_t File type and permission bits packed
st_mtime time_t Last modification time (seconds)
st_uid uid_t Owner's user ID
st_gid gid_t Owning group's ID
st_nlink nlink_t Number of hard links

off_t may be 64-bit even on 32-bit systems, so print it carefully (cast to long long and use %lld, or use intmax_t with %jd).

Pitfall. Assuming st_size is meaningful for every kind of file. For a directory it is implementation-defined; for a device file it is not a byte count. Only trust st_size once you have confirmed S_ISREG.

3. Decoding st_mode: file type

Definition. The high bits of st_mode encode the file type. You test them with the S_IS* macros, which take the whole mode and return true/false:

st_mode bit layout (conceptual):

  [ type bits ][ special ][ owner ][ group ][ other ]
   d/-/l...      suid...    rwx      rwx      rwx
Macro True when the file is...
S_ISREG(m) a regular file
S_ISDIR(m) a directory
S_ISLNK(m) a symbolic link (use with lstat)
S_ISFIFO(m) a named pipe (FIFO)
S_ISCHR(m) / S_ISBLK(m) a character / block device

When to use. Branch on type before doing type-specific work — e.g. only read contents from a regular file.

Pitfall. Comparing st_mode directly to a number like 0100644. That couples your code to one platform's bit values and is unreadable. Always use the macros.

Knowledge check (predict the output): If path is a directory, what does S_ISREG(st.st_mode) evaluate to — true or false? What about S_ISDIR(st.st_mode)?

4. Decoding st_mode: permission bits

Definition. The low nine bits of st_mode are the classic permissions, grouped into three sets of three:

 owner (user)   group        other
 r  w  x        r  w  x       r  w  x
 |  |  |        |  |  |       |  |  |
S_IRUSR  S_IXUSR   S_IWGRP    S_IROTH  S_IXOTH
   S_IWUSR  S_IRGRP   S_IXGRP    S_IWOTH

You test a bit with bitwise AND:

if (st.st_mode & S_IWOTH) { /* world-writable */ }

The full mask set:

Who Read Write Execute
Owner S_IRUSR S_IWUSR S_IXUSR
Group S_IRGRP S_IWGRP S_IXGRP
Other S_IROTH S_IWOTH S_IXOTH

When to use. To audit or display permissions, or to refuse to trust a file that is more open than it should be (e.g. a private key that is group- or world-readable).

Pitfall. Writing if (st.st_mode == S_IWOTH). Equality is wrong — st_mode holds many bits at once. You must mask with & and test for non-zero.

5. stat vs lstat and symlinks

Definition. A symbolic link is a small file whose contents are a path pointing at another file. stat follows the link and reports on the target; lstat does not follow it and reports on the link itself.

link.txt  --->  real.txt (1000 bytes)

stat("link.txt")  reports type=regular, size=1000  (the target)
lstat("link.txt") reports type=symlink, size=8     (the link's own path text)

When to use which. Use stat when you care about the data the path ultimately reaches. Use lstat when you must know whether the path is a link — for example a backup tool that should archive the link, not duplicate the target, or a security check that must not be tricked into following a link to a sensitive file.

Pitfall. Using stat in a security check and getting fooled because a symlink redirected you somewhere unexpected. When the link-vs-target distinction matters for trust, use lstat.

Syntax notes

Include the right headers and read the return value.

#include <sys/stat.h>   /* struct stat, stat, lstat, S_* macros */
#include <sys/types.h>  /* off_t, mode_t, etc. (POSIX) */

struct stat st;                 /* you provide the storage */
if (stat(path, &st) == -1) {    /* 0 = success, -1 = error  */
    perror("stat");             /* prints a human message + errno */
    /* handle the error; do NOT read st here */
}

/* type test (whole mode in, bool out) */
if (S_ISREG(st.st_mode)) { /* ... */ }

/* permission test (mask with &, check non-zero) */
if (st.st_mode & S_IRUSR) { /* owner can read */ }

/* printing a 64-bit-safe size */
printf("%lld bytes\n", (long long)st.st_size);

Key points:

  • stat/lstat take a path string and a pointer to your struct stat.
  • Type → use S_IS*(mode) macros. Permissions → use & S_I*** masks.
  • Always handle -1 before touching the struct.

Lesson

Reading file metadata with stat

The stat function reads information about a file without opening it for reading or writing.

int stat(const char *path, struct stat *out);

Given a path, it fills a struct stat (the out argument) with details such as:

  • Size of the file in bytes.
  • Type of the file: regular file, directory, or symbolic link (a symlink is a file that points to another file).
  • Modification time (mtime): when the contents last changed.
  • POSIX permission bits: who is allowed to read, write, or execute the file.

A common use: validate before opening

Call stat before you open a file. This lets you check the owner or the access mode first, so you can refuse to open a file that looks unsafe.

To test individual permission bits, combine the mode with mask constants such as S_IRUSR (owner can read) or S_IWOTH (others can write).

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/types.h>

/* Build a 9-char rwx string (like ls -l) from the permission bits. */
static void perm_string(mode_t m, char out[10]) {
    out[0] = (m & S_IRUSR) ? 'r' : '-';
    out[1] = (m & S_IWUSR) ? 'w' : '-';
    out[2] = (m & S_IXUSR) ? 'x' : '-';
    out[3] = (m & S_IRGRP) ? 'r' : '-';
    out[4] = (m & S_IWGRP) ? 'w' : '-';
    out[5] = (m & S_IXGRP) ? 'x' : '-';
    out[6] = (m & S_IROTH) ? 'r' : '-';
    out[7] = (m & S_IWOTH) ? 'w' : '-';
    out[8] = (m & S_IXOTH) ? 'x' : '-';
    out[9] = '\0';
}

static const char *type_name(mode_t m) {
    if (S_ISREG(m))  return "regular file";
    if (S_ISDIR(m))  return "directory";
    if (S_ISLNK(m))  return "symbolic link";
    if (S_ISFIFO(m)) return "named pipe";
    if (S_ISCHR(m))  return "character device";
    if (S_ISBLK(m))  return "block device";
    return "other";
}

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

    struct stat st;
    /* lstat: report on the path itself, even if it is a symlink */
    if (lstat(path, &st) == -1) {
        fprintf(stderr, "cannot stat '%s': %s\n", path, strerror(errno));
        return EXIT_FAILURE;
    }

    char perms[10];
    perm_string(st.st_mode, perms);

    printf("path : %s\n", path);
    printf("type : %s\n", type_name(st.st_mode));
    printf("size : %lld bytes\n", (long long)st.st_size);
    printf("perms: %s\n", perms);
    printf("owner: uid=%lld gid=%lld\n",
           (long long)st.st_uid, (long long)st.st_gid);

    /* A small safety check, the kind you would do before trusting a file. */
    if (st.st_mode & S_IWOTH) {
        printf("warning: this path is WORLD-WRITABLE\n");
    }

    /* Only treat it as readable content if it is a regular file. */
    if (S_ISREG(st.st_mode)) {
        printf("ok: safe to open as a regular file for reading\n");
    } else {
        printf("note: not a regular file; do not read it as text\n");
    }

    return EXIT_SUCCESS;
}

What it does. The program is a miniature stat(1)/ls -l for a single path passed on the command line. It calls lstat, then reports the file's type, size, permission string, and owner, and prints two checks: a warning if the path is world-writable, and a note about whether it is safe to open as a regular file.

Expected output (example — values depend on your file). For a normal text file created by you:

path : notes.txt
type : regular file
size : 42 bytes
perms: rw-r--r--
owner: uid=1000 gid=1000
ok: safe to open as a regular file for reading

For a directory you would instead see type : directory and the note: line.

Edge cases. If the path does not exist, lstat returns -1 with errno == ENOENT and the program prints a clean error and exits. If you lack search permission on a parent directory you get EACCES. Using lstat here means a symlink is reported as a symlink; switch to stat if you instead want the target's type and size.

Line by line

We trace the program for the command ./meta notes.txt, where notes.txt is a 42-byte regular file owned by uid 1000.

  1. Argument check. argc != 2 is false (we passed one path), so we skip the usage message. path now points at "notes.txt".
  2. struct stat st; reserves uninitialized storage on the stack. Nothing is valid in it yet.
  3. lstat(path, &st) asks the kernel to look up notes.txt and copy its inode metadata into st. It returns 0 (success), so we do not enter the error branch. After this line, st is fully populated.
  4. perm_string(st.st_mode, perms) reads the low nine bits of st_mode. For mode rw-r--r-- the table fills as below; each & either yields a non-zero value (bit set → letter) or 0 (bit clear → -).
index mask bit set? char
0 S_IRUSR yes r
1 S_IWUSR yes w
2 S_IXUSR no -
3 S_IRGRP yes r
4 S_IWGRP no -
5 S_IXGRP no -
6 S_IROTH yes r
7 S_IWOTH no -
8 S_IXOTH no -

So perms becomes "rw-r--r--" with a terminating '\0' at index 9. 5. type_name(st.st_mode) tests the type bits top-to-bottom. S_ISREG is true, so it returns "regular file" immediately and the later branches never run. 6. The printf block prints path, type, size, perms, and owner. st_size is cast to long long so %lld is safe regardless of off_t's real width; it prints 42. 7. World-writable check. st.st_mode & S_IWOTH evaluates to 0 (the other-write bit is clear), so the warning is skipped. 8. Regular-file check. S_ISREG(st.st_mode) is true, so we print the ok: line and return EXIT_SUCCESS (0).

If instead the file were missing, step 3's lstat would return -1, errno would be ENOENT, we would print cannot stat 'notes.txt': No such file or directory, and return EXIT_FAILURE without ever touching st.

Common mistakes

Mistake 1: Ignoring the return value of stat

struct stat st;
stat(path, &st);                 /* WRONG: result ignored */
printf("%lld\n", (long long)st.st_size);  /* st may be garbage */

Why wrong. If the file is missing or unreachable, stat returns -1 and leaves st uninitialized. You then print whatever happened to be on the stack. Fix:

if (stat(path, &st) == -1) { perror("stat"); return 1; }
printf("%lld\n", (long long)st.st_size);

Recognize it: random or zero sizes, no error on a name you mistyped. Always branch on == -1 first.

Mistake 2: Comparing st_mode with ==

if (st.st_mode == S_IWOTH) { /* WRONG */ }

Why wrong. st_mode packs the type and all nine permission bits together, so it almost never equals a single mask. Fix: mask, then test for non-zero:

if (st.st_mode & S_IWOTH) { /* world-writable */ }

Mistake 3: Hard-coding octal instead of macros

if ((st.st_mode & 0170000) == 0040000) { /* directory? WRONG-ish */ }

Why wrong. Magic numbers are unreadable and not portable. Fix: if (S_ISDIR(st.st_mode)).

Mistake 4: Using stat when you meant lstat (or vice-versa)

Using stat on a symlink reports the target, so S_ISLNK is never true. If you are checking "is this a link?" you must use lstat. Conversely, if you want the target's size, lstat gives you the tiny link size instead. Recognize it: symlink checks that never fire, or sizes that are suspiciously small (like 8 or 12 bytes).

Mistake 5: Printing st_size with %d or %ld

off_t is often 64-bit. printf("%d", st.st_size) is undefined behavior and prints wrong values for large files. Fix: cast to long long and use %lld, or use %jd with (intmax_t).

Debugging tips

Compiler errors

  • unknown type name 'mode_t' or implicit declaration of 'stat' → you forgot #include <sys/stat.h> (and on some systems <sys/types.h>).
  • 'S_ISREG' undeclared → same missing header.
  • Format-string warnings on st_size (format '%d' expects 'int' but argument has type 'off_t') → cast to (long long) and use %lld. Compile with -Wall -Wextra so these surface.

Runtime errors

  • stat returns -1: print the reason with perror("stat") or strerror(errno). Common errno values: ENOENT (no such file), EACCES (no search permission on a parent dir), ENOTDIR (a path component is not a directory), ELOOP (too many symlink hops).
  • All-zero output: you probably read st after stat failed. Add the -1 check.

Logic errors

  • A permission test never triggers → you may be using == instead of &, or testing the wrong who/what mask (e.g. S_IWUSR instead of S_IWOTH).
  • S_ISLNK never true → you used stat, which follows links; switch to lstat.

Questions to ask when it doesn't work

  1. Did I check stat's return value before reading the struct?
  2. Is the header included and am I compiling with warnings on?
  3. Am I masking with & for permissions and using S_IS* for type?
  4. Do I want the link or the target — lstat or stat?
  5. Does ls -l <path> agree with what my program prints? (Great cross-check.)

Memory safety

stat itself does not allocate memory, but a few correctness and undefined-behavior concerns apply to this topic:

  • Uninitialized reads. If you read struct stat fields after stat returned -1, you are reading indeterminate values — undefined behavior. Always gate field access on a successful return.
  • Provide valid storage. Pass the address of a real struct stat (&st). Passing a wild or NULL pointer is undefined behavior; the kernel writes through it.
  • Integer width of st_size. Treating a 64-bit off_t as a 32-bit int (in a format string or an assignment) truncates large sizes silently. Use long long/%lld or intmax_t/%jd.
  • TOCTOU race (robustness/security). Metadata can change between your stat and a later open/fopen. Never rely on the stat result as a guarantee — re-check errors when you actually open, and for security-sensitive code open first and then fstat the resulting descriptor so the check and use refer to the same file.
  • Symlink safety. When validating untrusted paths, prefer lstat so an attacker cannot redirect your check through a symlink to a file you did not intend to touch.
  • Don't trust st_size for non-regular files. Only use it after confirming S_ISREG; for directories and devices the value is not a content byte count.

Real-world uses

Concrete uses

  • ls -l reads stat/lstat for every entry to print type, permissions, size, owner, and modification time — the exact fields covered here.
  • Backup and sync tools (rsync, tar) compare st_mtime and st_size to decide what changed, and use lstat to preserve symlinks instead of copying their targets.
  • Web servers stat a requested file to send the correct Content-Length and to send 304 Not Modified based on modification time, and to refuse serving a directory.
  • Security/integrity tools (file-integrity monitors, SSH itself) check permission bits — e.g. SSH refuses to use a private key if it is group- or world-readable.

Professional best-practice habits

Beginner rules:

  • Always check stat's return value before reading the struct.
  • Use the S_IS* macros for type and & S_I*** masks for permissions — never magic octals.
  • Print sizes with a width-safe format (%lld + cast, or %jd).
  • Report errors with perror/strerror(errno) so failures are diagnosable.

Advanced rules:

  • Choose stat vs lstat deliberately based on whether you mean the target or the link.
  • For security-sensitive checks, avoid the TOCTOU race: open the file, then fstat the descriptor, and act on that descriptor.
  • Validate type and permissions before opening untrusted paths; log unexpected metadata (e.g. world-writable config) but never log secrets.

Practice tasks

Beginner 1 — File size reporter. Write a program that takes a path on the command line, calls stat, and prints "<path>: <size> bytes". Requirements: check the return value; on failure print an error with strerror(errno) and exit non-zero. Constraints: print the size with %lld and a cast. Hint: this mirrors the file-permissions-stdio-ex1 exercise. Concepts: stat, st_size, error handling.

Beginner 2 — Type classifier. Extend the above so it also prints one of regular, directory, symlink, or other. Requirements: use lstat so symlinks are detected; branch with S_ISREG, S_ISDIR, S_ISLNK. Output example: report.txt: regular, 18 bytes. Hint: relates to file-permissions-stdio-ex2 (S_ISREG). Concepts: lstat, type macros.

Intermediate 1 — rwx printer. Print the nine-character permission string (rwxr-xr-x) for a path, matching the order in ls -l. Requirements: test each of the nine masks; build a 10-char buffer (9 chars + '\0'). Constraints: no magic numbers. Hint: reuse the perm_string pattern from the lesson code. Concepts: permission masks, bitwise &.

Intermediate 2 — World-writable auditor. Given a list of paths as command-line arguments, print only those that are world-writable, prefixed with WARN:. Requirements: loop over argv; stat each; skip (with an error line) any that fail. Input example: ./audit /etc/hosts ./shared.conf. Hint: test st.st_mode & S_IWOTH. Concepts: iteration, masks, robust error handling.

Challenge — Mini ls -l line. For a single path, print one line resembling -rw-r--r-- 1 1000 1000 42 notes.txt: a type char (-/d/l), the rwx string, the link count (st_nlink), uid, gid, size, and name. Requirements: use lstat; pick the type char from the S_IS* macros; format the size width-safely. Constraints: handle a missing file cleanly. Hint: combine the type-char, perm_string, and the numeric fields. Concepts: lstat, type + permission decoding, st_nlink, careful formatting.

Summary

  • Metadata vs. contents. stat(path, &st) fills a struct stat with facts about a file — size, type, times, owner, permissions — without opening it for I/O. It returns 0 on success and -1 (with errno) on failure; always check before reading the struct.
  • Key fields. st_size (byte size, trustworthy only for regular files), st_mode (type and permission bits packed together), st_mtime, st_uid/st_gid, st_nlink. Print st_size width-safely with %lld + a cast.
  • Decoding st_mode. Use the S_IS*(mode) macros for type (S_ISREG, S_ISDIR, S_ISLNK, ...) and mode & S_I*** masks for permissions (S_IRUSR, S_IWGRP, S_IWOTH, ...). Never compare with == and never hard-code octal.
  • stat vs lstat. stat follows symlinks (reports the target); lstat does not (reports the link). Choose by whether you mean the link or what it points to, and prefer lstat for security checks.
  • Common mistakes. Ignoring the return value, using == instead of &, magic octals, the wrong link function, and printing off_t with %d.
  • Remember: look before you leap — inspect a file's type and permissions before trusting or opening it, but still handle open errors, because metadata can change underneath you (TOCTOU).

Practice with these exercises