Files, directories and links
Create, copy, move and delete safely; links and inodes.
This lesson covers the everyday work of creating, copying, moving and deleting files and directories, and the error messages each of those commands gives when something is in the way. It also shows what a move really does on disk, how hard and symbolic links work, and why the kernel refuses some operations in /tmp even to root. Knowing these details is what lets you delete and overwrite things on a server without fear, and read the error when a command refuses.
Creating files and directories
The examples work in a practice directory, ~/cmd-files, in the home directory. mkdir makes a directory, and -p makes any missing parent directories on the way. touch creates an empty file (on an existing file it only updates the file's times), and echo with > writes a line of text into a file; the lesson on pipes and redirection explains >. Each terminal starts in the home directory, so the steps that work inside the practice directory begin with cd ~/cmd-files, which is harmless to repeat.
ls -lR lists a directory and everything below it, one block per directory. site is a directory (the d at the start of its line) holding css and the empty index.html, and notes.txt is 12 bytes: the text plus the newline at its end. Without -p, mkdir refuses to create a directory that already exists:
This is the wording of the Rust mkdir on Ubuntu 26.04; the GNU version says cannot create directory before the name. With -p the same command succeeds silently, which is why scripts use it.
Copying: cp, cp -r and cp -a
cp SOURCE DESTINATION copies a file and leaves the original alone. Copying a file next to itself before you edit it is a cheap backup. A directory needs -r (recursive), and without it cp skips the directory and says so:
A plain copy is a new file owned by you, with the current time. -a (archive) keeps the original's modification time and permissions as well, and its owner when you run it as root, which is what you want for a backup of a configuration file:
exact-copy still shows the time /etc/hostname was last changed; plain-copy shows the time of the copy. The owner is deploy in both because an ordinary user cannot create files owned by someone else. sudo cp -a /etc/ssh/sshd_config /etc/ssh/sshd_config.orig is the kind of backup this is for.
Moving: a rename, or a copy and a delete
To understand mv, you need one idea from how filesystems work. A file's name is only an entry in a directory. The file itself, meaning its data and details such as owner, permissions and times, is an inode, and every inode has a number on its filesystem. ls -i prints it.
The inode number did not change. Within one filesystem, mv only rewrites the directory entry, which is why moving a 50 GB file to another directory on the same filesystem is instant. Moving to a different filesystem is another matter. On Ubuntu 26.04 your home directory is on the root filesystem while /tmp is a separate tmpfs:
strace shows the steps (the lines left out load libraries and language files, and check whether the destination exists). The kernel refuses the rename with EXDEV, because a directory entry cannot point to an inode on another filesystem. mv then opens the source, creates the destination, copies the bytes and only at the end deletes the source with unlinkat. The result is a new inode:
That order decides what happens when a move across filesystems is cut off. When an SSH connection drops, the programs started from that session receive the hangup signal (SIGHUP). The lab simulates that here: it creates a 300 MB file of random bytes with head -c, starts moving it to /tmp, and sends mv the hangup signal as soon as the copy has begun. To try it yourself, run the mv in one session and type pkill -HUP -x mv in a second one straight away (the lesson on signals explains pkill).
The original is complete, and a partial copy of under 9 MB (8867840 bytes) sits in /tmp, still with the private mode rw------- that mv gives it while copying. When the copy fails with an error instead, such as a full disk, GNU mv deletes the partial copy itself. Either way the source is removed only after a complete copy, so the fix is to delete the partial file and run the move again. On RHEL 10, /tmp is on the root filesystem, so the same move there is a rename.
Deleting, and the errors that protect you
rm deletes files and rm -r deletes a directory with everything in it. There is no recycle bin: the directory entry is gone at once, and the space is freed when no other name or open program still uses the inode. rmdir deletes only empty directories, which makes it the safe choice when you expect a directory to be empty. rm -i asks before each deletion.
Deleting a file changes the directory that lists it, not the file, so your right to delete depends on the directory. That has an important consequence in shared directories such as /tmp. Here root creates a file there that everyone may write to:
chmod 666 lets every user read and write the file (the permissions lesson explains the numbers). The file is writable by every user, and deploy still cannot delete it. /tmp is writable by everyone but carries the sticky bit (the t in drwxrwxrwt), which lets only the file's owner, the directory's owner or root delete or rename a file there. Without it, any user could delete or replace any other user's temporary files. The lesson on special permission bits covers the sticky bit fully.
rm -rf deletes recursively and suppresses every question, so a typo is carried out in full. A stray space in rm -rf ~ /build deletes your whole home directory before it reaches /build, and an empty variable turns rm -rf "$BUILD_DIR"/* into rm -rf /*. GNU rm refuses to delete / itself, but /* is a list of every top-level directory, and nothing refuses that. Run ls on the exact path first, prefer absolute paths, and in scripts write ${BUILD_DIR:?} (variables are covered in the lesson on environment variables): when the variable is empty or unset the shell stops with an error instead of running the command.The rm never ran: the shell refused to expand the empty variable and deleted nothing. Scripts can add set -u at the top, which makes every unset variable an error; it does not catch a variable that is set but empty, which ${VAR:?} does.
Hard links, symbolic links and inodes
Because names and inodes are separate, one inode can have several names. ln creates a hard link, a second directory entry for the same inode:
Both names show the same inode number, and the number after the permissions, the link count, is now 2. Neither name is the original; they are equal. stat shows the inode's details. The first Access line is the permissions, and the second the time of the last read. Modify is the last change to the content, Change the last change to the inode itself (adding a link counts), and Birth when this inode was created. Here Birth is later than Modify: the file came back from /tmp with a move across filesystems, which created a new inode and copied the old modification time. touch -d can set Modify to any date, but the kernel sets Change to the current time whenever inode information changes, which is why investigators compare the two. Deleting a name only lowers the link count; the data stays until the last name is gone:
A hard link cannot cross filesystems, for the same reason a rename cannot. A symbolic link (symlink), made with ln -s, is different: it is a small file that holds a path, and the kernel follows that path whenever a program opens the link.
ls -l shows a symlink with type l and an arrow to the path it holds; its size, 13, is the length of that path. The link does not follow its target: after the rename it still points to todo-copy.txt, which no longer exists, so it dangles and opening it fails. Symlinks can point anywhere, across filesystems and to directories, and you have already met several: /bin points to usr/bin and /usr/bin/ls into the Rust coreutils.
Why /tmp refuses some symbolic links
Because a symlink can point anywhere, it used to be a classic attack in shared directories. An attacker who guesses that a root-run program will write /tmp/report.txt creates a symlink with that name pointing at, say, /etc/passwd, and the program overwrites the wrong file. Current kernels block the simplest form of this. Watch what happens when root tries to read a link that deploy made in /tmp:
The owner of the link can follow it, and root gets Permission denied. With the kernel setting fs.protected_symlinks at 1, the default on Ubuntu 26.04 and RHEL 10, the kernel refuses to follow a symlink in a world-writable sticky directory unless the link belongs to the process following it or to the directory's owner. It does not cover every case, so programs should still create temporary files with mktemp, which picks a random, unguessable name, rather than with fixed names.
Try this
In your own practice directory, create a file with some text, then a symbolic link to it with ln -s. Rename the file so the link dangles, confirm with cat, and repair the link with ln -sf NEWNAME LINKNAME (-f replaces the existing link). Then give the file a second hard name with ln and check with ls -li that both names share one inode and a link count of 2. Finish by removing the practice directory with rm -r ~/cmd-files, the root-owned file with sudo rm /tmp/cmd-files-shared.txt and your link with rm /tmp/cmd-files-link.
Takeaway
Before you delete or overwrite, look at what the path really is with ls -l or ls -li, and let the error messages stop you rather than forcing past them. A move across filesystems copies first and deletes last, so an interrupted move never loses the original; clean up the partial copy and run it again.