How to Fix: Directory read bug.

6 min read

Directory reads break when a directory file descriptor is treated like a regular file stream, causing readdir, offset handling, or repeated scans to return inconsistent results.

This issue usually appears in low-level runtime, libc-compatibility, sandbox, or virtual filesystem layers where directory iteration is implemented manually. The failing C test case strongly suggests a bug in how directory entries are read, buffered, or rewound after opening a directory with file-descriptor-based APIs.

Problem Overview

The issue title, Directory read bug, points to a failure in directory iteration logic. In POSIX systems, directories are not read the same way as normal files. While both are represented by file descriptors at the kernel level, user-space directory traversal normally goes through opendir, fdopendir, and readdir, which maintain internal state and buffering rules.

If an implementation mixes raw read() behavior, incorrect offset tracking, or invalid buffer parsing for directory entries, a test can fail in several ways:

  • readdir() returns missing entries
  • the same entry appears more than once
  • end-of-directory is reached too early
  • errno is set incorrectly
  • reopening or rewinding the directory produces corrupted results

The included headers in the test case, especially dirent.h, fcntl.h, unistd.h, errno.h, and string.h, indicate the bug likely involves direct file descriptor usage combined with directory iteration validation.

Understanding the Root Cause

The root cause is usually one of these implementation mistakes:

1. Treating directories like regular files

A directory descriptor is not meant to be consumed with ordinary file semantics in portable code. On many systems, directory entry records are returned in a kernel-specific binary format. readdir() depends on a proper DIR structure, internal buffering, and record alignment. If your runtime reads bytes directly and assumes fixed-size records, parsing becomes unreliable.

2. Broken offset management

Directory iteration depends on a logical position within the directory stream. If the implementation mishandles seekdir, telldir, rewinddir, or internal offsets, then subsequent calls can skip entries or reread old ones. A common bug is updating the offset before validating the current record length.

3. Invalid handling of variable-length dirent records

On many platforms, directory entries are variable length. The parser must advance by d_reclen, not by sizeof(struct dirent). Using the structure size instead of the recorded entry length causes misalignment and data corruption.

4. Incorrect end-of-stream behavior

At end-of-directory, readdir() returns NULL and does not automatically indicate an error. The caller must set errno = 0 before calling readdir() if it wants to distinguish end-of-stream from failure. If your implementation sets or preserves errno incorrectly, tests that validate POSIX behavior will fail.

5. Mixing raw file descriptor operations with DIR stream state

If code calls read(), lseek(), or other descriptor-level operations after wrapping the descriptor with fdopendir(), the stream state can become inconsistent. Once a descriptor is owned by a DIR*, directory traversal should be performed through directory APIs only.

Step-by-Step Solution

The safest fix is to ensure the directory is handled through a proper directory stream abstraction and that entry parsing respects platform rules.

Step 1: Open the directory correctly

If you already have a directory file descriptor, convert it with fdopendir() instead of reading from it manually.

#include <dirent.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

int main(void) {
    int fd = open(".", O_RDONLY | O_DIRECTORY);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    DIR *dir = fdopendir(fd);
    if (!dir) {
        perror("fdopendir");
        close(fd);
        return 1;
    }

    struct dirent *entry;
    errno = 0;
    while ((entry = readdir(dir)) != NULL) {
        printf("%s\n", entry->d_name);
        errno = 0;
    }

    if (errno != 0) {
        perror("readdir");
        closedir(dir);
        return 1;
    }

    closedir(dir);
    return 0;
}

Why this works: it delegates buffering, record parsing, and offset tracking to the directory stream implementation instead of incorrectly reusing plain file I/O semantics.

Step 2: If implementing readdir internally, parse records using reclen

If you are building a libc, runtime, or syscall translation layer, never advance through a directory buffer with sizeof(struct dirent). Use the record length supplied by the underlying directory entry format.

size_t offset = 0;
while (offset < bytes_read) {
    struct dirent_like *rec = (struct dirent_like *)(buffer + offset);

    if (rec->d_reclen == 0) {
        errno = EIO;
        return NULL;
    }

    process_entry(rec);
    offset += rec->d_reclen;
}

Key fix: validate that d_reclen is non-zero, aligned, and does not move past the buffer boundary.

Step 3: Preserve correct end-of-directory semantics

Your implementation should differentiate between a clean end-of-stream and a real error.

errno = 0;
struct dirent *ent = readdir(dir);
if (ent == NULL) {
    if (errno == 0) {
        /* End of directory */
    } else {
        /* Actual error */
        perror("readdir");
    }
}

If your custom implementation returns NULL while leaving a stale non-zero errno, tests may incorrectly interpret normal completion as failure.

Step 4: Do not mix stream and descriptor positioning

After calling fdopendir(fd), avoid direct lseek(fd, …) or read(fd, …) calls. If you need restart behavior, use directory APIs:

rewinddir(dir);

errno = 0;
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
    puts(entry->d_name);
    errno = 0;
}

This keeps the internal directory stream state consistent.

Step 5: Filter special entries intentionally

Some tests fail because implementations unexpectedly include or exclude . and ... If your logic compares directory contents, decide explicitly whether to keep them.

while ((entry = readdir(dir)) != NULL) {
    if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
        continue;
    }
    printf("real entry: %s\n", entry->d_name);
}

Step 6: Validate against the failing test pattern

For a test case like the reported one, check these assertions:

  • opening a directory with open(…, O_DIRECTORY) succeeds
  • fdopendir() attaches correctly to the descriptor
  • readdir() returns all expected entries
  • no duplicate entries appear unless the directory truly contains duplicates by name in separate contexts
  • errno remains correct on end-of-directory
  • closedir() properly releases the descriptor

Common Edge Cases

Large directories

If the directory contains many entries, partial buffer reads become more likely. A broken implementation may parse an incomplete trailing record. Always refill the buffer safely and only parse complete records.

Concurrent directory mutation

If files are added or removed while iterating, POSIX does not guarantee a perfectly stable listing. Your code should not crash or enter an infinite loop when directory contents change mid-scan.

Non-standard filesystems

Virtual filesystems, network filesystems, and sandboxed environments may expose directory iteration quirks. A custom compatibility layer must avoid hardcoding assumptions about entry layout beyond the platform contract.

Incorrect ownership of the file descriptor

After fdopendir(), the descriptor is managed by the directory stream. Calling close(fd) manually before closedir() can trigger undefined behavior or later read failures.

Failure to reset errno before validation

Many developers misread readdir() results because errno still contains an older error. Always clear it before the call if you need to inspect end-of-directory versus error behavior.

Assuming deterministic ordering

Directory entry order is not guaranteed. If the failing test compares output in a fixed order, sort the results in the test harness rather than relying on filesystem enumeration order.

FAQ

Why does reading a directory with read() cause problems?

Because directories expose implementation-specific record formats. readdir() exists to interpret those records safely, manage buffering, and advance positions correctly.

Should I use opendir() or fdopendir() for this bug?

Use opendir() when you start from a path. Use fdopendir() when you already have a valid directory file descriptor. Both are preferable to manual raw reads for normal iteration.

Why does readdir() return NULL even when nothing is wrong?

NULL means either end-of-directory or an error. Set errno = 0 before calling it. If it returns NULL and errno is still zero, iteration completed normally.

Conclusion

To fix a directory read bug, stop treating directory descriptors like regular files, use fdopendir/readdir correctly, honor variable-length dirent records, and preserve POSIX errno semantics. In most implementations, the real defect is not the directory itself but broken state management between low-level descriptor access and high-level directory stream traversal.

Leave a Reply

Your email address will not be published. Required fields are marked *