Filesystems 101: Inodes, Directories, and What a Path Lookup Really Does
This article covers Filesystems 101: Inodes, Directories, and What a Path Lookup Really Does. Understand the core filesystem abstractions: inodes, dentries, hard links, symlinks, and why path lookup can be expensive. Includes practical...
When you call open("/home/user/file.txt"), the kernel performs a surprising amount of work:
- parse path components
- walk directories
- resolve symlinks
- check permissions
- create or locate an inode
Understanding these fundamentals helps you reason about performance (lots of tiny files), correctness (links), and security (TOCTOU).
1) Inodes: the file’s identity
On many Unix filesystems, the inode stores metadata:
- file type (regular file, directory, symlink, device, ...)
- permissions, owner, timestamps
- file size
- pointers to data blocks (implementation-specific)
Important: filenames are not “stored in the inode”. Filenames live in directories.
2) Directories are maps: name → inode
A directory is a special file whose content encodes entries mapping:
- filename
- inode number
Hard links:
- multiple directory entries pointing to the same inode
- same inode number, different names
Symlinks:
- a file containing a path to another file
- symlink resolution can cross filesystem boundaries
3) Path lookup and the dentry cache
Linux caches name lookups using dentry objects.
- Hot paths can be fast due to cache hits.
- Cold paths (many unique directories) can be slow.
If you create tools that touch millions of files (build systems, backups, package managers), path lookup and metadata I/O often dominates.
3.1) Link counts and what they mean
st_nlink is the number of directory entries (hard links) pointing at the inode.
- A regular file usually starts at 1.
- A directory's link count reflects its
.entry plus subdirectories.
You can use link counts to reason about whether a file has additional names elsewhere.
4) C: inspect inode numbers and link counts
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: %s <path>\n", argv[0]);
return 2;
}
struct stat st;
if (lstat(argv[1], &st) != 0) {
fprintf(stderr, "lstat: %s\n", strerror(errno));
return 1;
}
printf("inode: %lu\n", (unsigned long)st.st_ino);
printf("links: %lu\n", (unsigned long)st.st_nlink);
printf("size: %lu\n", (unsigned long)st.st_size);
if (S_ISLNK(st.st_mode)) {
printf("type: symlink\n");
} else if (S_ISDIR(st.st_mode)) {
printf("type: directory\n");
} else if (S_ISREG(st.st_mode)) {
printf("type: regular\n");
} else {
printf("type: other\n");
}
return 0;
}
Use lstat (not stat) when you want information about the symlink itself.
5) Zig: list directory entries
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const a = gpa.allocator();
var args = try std.process.argsAlloc(a);
defer std.process.argsFree(a, args);
if (args.len != 2) return error.InvalidArgs;
var dir = try std.fs.cwd().openDir(args[1], .{ .iterate = true });
defer dir.close();
var it = dir.iterate();
while (try it.next()) |e| {
std.debug.print("{s} ({any})\n", .{ e.name, e.kind });
}
}
6) Rust: metadata and symlink awareness
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::args().nth(1).expect("path");
let md = fs::symlink_metadata(&path)?; // like lstat
println!("len: {}", md.len());
println!("is_file: {}", md.is_file());
println!("is_dir: {}", md.is_dir());
println!("is_symlink: {}", md.file_type().is_symlink());
Ok(())
}
7) Security pitfall: TOCTOU
A classic mistake:
- check permissions with
stat - later call
open
Between the check and the use, an attacker can replace the file (symlink swap).
Mitigations:
- open first, then
fstat - use
openatwith directory fds - consider
O_NOFOLLOWandO_CLOEXEC
8) Rename is special (and why databases love it)
Within a filesystem, rename is typically atomic: after a crash, you should see either the old name or the new name.
That makes it a common commit primitive for safe updates:
- write a temp file
fsyncthe temp filerenameover the old file- (optionally)
fsyncthe directory
Related:
References
stat(2)/lstat(2): https://man7.org/linux/man-pages/man2/stat.2.htmlopen(2): https://man7.org/linux/man-pages/man2/open.2.htmlopenat(2): https://man7.org/linux/man-pages/man2/openat.2.html- Linux VFS docs (high-level background): https://www.kernel.org/doc/html/latest/filesystems/vfs.html