Files, Paths and Names
A filename is a directory entry that points at a file object; the object has metadata, permissions and blocks, and it can outlive — or have several — names.
The problem
app.log while the server still has it open. On Linux the server keeps writing and the disk keeps filling, but ls shows nothing. Where did the bytes go — and what, exactly, did rm remove?A name is not a file
The thing you call a file is two things. The file object is metadata (type, size, owner, permission bits, timestamps) plus a map from byte offsets to blocks on storage. The name is an entry in a directory: a string paired with a reference to that object. On Unix-style systems the reference is an inode number (Inodes); on NTFS it is a record number in the Master File Table. Either way the directory entry is the pointer, not the thing.
This split explains behaviour that looks paradoxical if you think of a file as "the bytes at this path". A hard link is a second directory entry pointing at the same object: ln a b gives the object two names, stat shows a link count of 2, and editing through either name edits the one object. A symbolic link is a different object whose content is a path — it can dangle, cross file systems, and point at a directory.
A directory is itself a file object whose content is a list of (name, reference) pairs. Renaming moves an entry between directories; it does not touch the object or its blocks, which is why mv within one file system is instant for a 100 GB file and why mv across file systems is a copy followed by a delete.
- One object, many names: hard links. One name, one path-shaped object: a symlink.
- The object never stores its own name.
find -inumis how you go backwards. - Windows/NTFS supports hard links too (
mklink /H), but most Windows tooling assumes one name per file.
What happens when you delete an open file
On Unix, rm calls unlink(2): it removes the directory entry and decrements the object’s link count. The object’s storage is reclaimed only when the link count reaches zero and no process holds it open. A server that opened app.log still has a descriptor (File Descriptors) referring to the object, so the object lives on with no name. It keeps growing; du cannot see it; df can. The classic symptom is a disk at 100% with nothing large in any directory.
lsof +L1 (or lsof | grep deleted) lists exactly these nameless files, and /proc/<pid>/fd/<n> still lets you read the content. The fix is to make the process close or reopen the file (SIGHUP for most log-rotation-aware daemons, or copytruncate in logrotate) — deleting the name again does nothing. The same mechanism is used deliberately: open then unlink gives a temporary file that vanishes automatically when the process exits, which is what tmpfile(3) and O_TMPFILE do.
Windows differs, and the difference matters if you ship cross-platform. By default a file opened without FILE_SHARE_DELETE cannot be deleted or renamed while open — you get ERROR_SHARING_VIOLATION, the "file is in use" dialog. Even with the share flag, the name persists in a deleted-pending state until the last handle closes, so creating a new file with the same name may fail. Replace-while-running (atomic rename over a running binary or a config being read) is a Unix idiom that does not port unchanged.
$ df -h /var/log Filesystem Size Used Avail Use% Mounted on /dev/nvme0n1p2 98G 97G 1.1G 99% / $ du -sh /var/log 412M /var/log $ lsof +L1 | head -3 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME server 41822 app 4w REG 259,2 84213487616 0 918273 /var/log/app.log (deleted)
Absolute, relative, and the current working directory
A path is a list of names separated by /. An absolute path starts at the root directory; a relative path starts at the process’s current working directory, which is per-process state kept by the kernel — not a shell concept, not a global. chdir(2) changes it for the calling process only; a child inherits it at fork and keeps its own copy afterwards. That is why cd must be a shell builtin: a /bin/cd binary would change its own cwd and exit.
The cwd is a reference to a directory object, not a stored string. If the directory is renamed under you, getcwd returns the new path; if it is deleted, getcwd fails with ENOENT and relative opens fail — the familiar "shell: getcwd: cannot access parent directories" after a deploy replaced the directory you were sitting in. Daemons chdir("/") at startup precisely so they never pin a mount point or a deleted directory.
Relative paths are a portability and security trap in code: a service started by systemd, by cron, and by hand has three different cwds. Resolve paths against a known anchor (the executable’s directory, an env var, or openat with a directory descriptor) rather than trusting where the process happened to start.
.is the directory itself,..its parent; at the root,..is the root again.- Per-process: cwd, root directory (
chroot), umask. All three are inherited acrossforkand surviveexec. - On Windows there is a cwd per drive letter in the C runtime and a single process-wide one in the kernel; UNC paths and
\?prefixes have their own rules. Label your path code accordingly.
Path resolution, one component at a time
Opening /var/log/app.log is not one lookup. The kernel starts at the root (or the cwd for a relative path) and for each component in turn: reads the current directory, finds the entry with that name, checks that the process may search the directory (the execute bit on a directory means "traverse", not "run"), follows a symbolic link if the entry is one (restarting resolution from the link’s content; Linux caps the number of links at 40 and returns ELOOP), crosses a mount point if one is stacked there, and moves on. Only the last component is checked for read or write permission.
Each step is a lookup in the dentry cache on Linux, a hash from (parent directory, name) to the resolved object, so a hot path costs tens of nanoseconds per component with no disk access. A cold path — the first ls after boot, or a container with a 12-layer overlay file system — walks directories on storage and can take milliseconds. Deep paths and long PATH variables cost real time on cold caches: a shell searching 20 directories for node does 20 failed lookups per command.
Permission bits are checked against the process’s effective user and group: three triplets rwx for owner, group, other, plus setuid, setgid and sticky bits. chmod 644 is rw-r--r--. Removing a file requires write permission on the directory, not on the file — a read-only file in a writable directory is deletable, which surprises everyone once. Windows uses ACLs on each object instead of mode bits; the traversal-permission concept exists but is checked differently.
- Root directory `/`process root (chroot) or the global root↓
- Entry `var` → directorysearch permission (x) on `/` checked; dentry cache hit↓
- Entry `log` → directorysearch permission on `/var`; mount point? then switch to the mounted file system’s root↓
- Entry `app.log` → file objectwrite permission on the object itself; symlink? restart from its target↓
- Open file description + descriptoroffset 0 (or end with O_APPEND), flags recorded, fd number returned
Key points
- A filename is a directory entry pointing at a file object. The object has the metadata and the blocks; it does not know its own name.
- Hard links are extra names for one object; symlinks are objects whose content is a path.
- Unix
unlinkremoves a name. Storage is freed when the link count is zero and no descriptor is open — so deleted-but-open files silently eat disk. - Windows refuses to delete or rename an open file unless it was opened with
FILE_SHARE_DELETE; the idioms do not port unchanged. - The current working directory is per-process kernel state, inherited at fork, referenced by object not by string.
- Resolution walks the path component by component, checking search permission on every directory and only read/write on the final object.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why separate the name from the object at all?
So that renaming is a metadata edit, not a copy; so one object can have several names; and so an open file stays valid while its name changes — a process reads a consistent object even as the directory around it is rewritten.
▸Why is the execute bit on a directory called "search"?
Because looking up a name inside a directory is a distinct operation from listing it (read). --x on a directory lets you reach files you already know the names of without letting you enumerate them — the mechanism behind shared drop-boxes and /home protection.
▸Why does every process carry its own cwd?
Because two processes started from different places must resolve ./config.yml differently, and because the OS cannot know which of a process’s threads "means" a relative path. Making it process state is the smallest scope that still allows cd to work in a shell.
How it fails
What the failure looks like from inside real software.
- Disk full, nothing large in any directory: a rotated or deleted log is still open by a running process (
lsof +L1). - Cron job or systemd unit fails with "no such file" while the same command works in your shell: relative path resolved against a different cwd.
- "getcwd: cannot access parent directories" after a deploy replaced the directory a shell was sitting in — the old directory object is deleted, the shell still holds it.
- Cross-platform tool that atomically replaces a file with
renameworks on Linux and throwsEPERM/sharing violation on Windows because the target is open. - A backup or sync tool copies the same 20 GB object three times because it treats three hard links as three files (or, inversely, restores them as three independent copies).
- Permission denied on
openalthough the file isrw-rw-rw-: a directory in the path lacks the search bit for that user.