Files, directories and links

Create, copy, move and delete safely; links and inodes.

Beginner14 min · lesson 4 of 29

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.

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir -p ~/cmd-files/site/css
$ cd ~/cmd-files touch site/index.html site/css/style.css
$ cd ~/cmd-files echo "first draft" > notes.txt
$ ls -lR ~/cmd-files
/home/deploy/cmd-files: total 8 -rw-rw-r-- 1 deploy deploy 12 Sep 27 08:06 notes.txt drwxrwxr-x 3 deploy deploy 4096 Sep 27 08:06 site /home/deploy/cmd-files/site: total 4 drwxrwxr-x 2 deploy deploy 4096 Sep 27 08:06 css -rw-rw-r-- 1 deploy deploy 0 Sep 27 08:06 index.html /home/deploy/cmd-files/site/css: total 0 -rw-rw-r-- 1 deploy deploy 0 Sep 27 08:06 style.css

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:

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir ~/cmd-files/site
mkdir: /home/deploy/cmd-files/site: File 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:

deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/cmd-files cp site/index.html site/index.html.bak
$ cd ~/cmd-files cp site site-backup
cp: -r not specified; omitting directory 'site'
$ cd ~/cmd-files cp -r site site-backup ls -l
total 12 -rw-rw-r-- 1 deploy deploy 12 Sep 27 08:06 notes.txt drwxrwxr-x 3 deploy deploy 4096 Sep 27 08:06 site drwxrwxr-x 3 deploy deploy 4096 Sep 27 08:06 site-backup

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:

deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/cmd-files cp /etc/hostname plain-copy cp -a /etc/hostname exact-copy ls -l plain-copy exact-copy
-rw-r--r-- 1 deploy deploy 6 Sep 26 20:01 exact-copy -rw-r--r-- 1 deploy deploy 6 Sep 27 08:06 plain-copy

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.

deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/cmd-files ls -i notes.txt mv notes.txt todo.txt ls -i todo.txt
575530 notes.txt 575530 todo.txt

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:

deploy@web01 · Ubuntu 26.04 LTS
$ findmnt -T ~/cmd-files findmnt -T /tmp
TARGET SOURCE FSTYPE OPTIONS / /dev/vda1 ext4 rw,relatime,discard,errors=remount-ro,commit=30 TARGET SOURCE FSTYPE OPTIONS /tmp tmpfs tmpfs rw,nosuid,nodev,size=1994676k,nr_inodes=1048576,inode64,usrquota
$ cd ~/cmd-files strace -e trace=renameat2,openat,write,unlinkat mv todo.txt /tmp/cmd-files-todo.txt
… renameat2(AT_FDCWD, "todo.txt", AT_FDCWD, "/tmp/cmd-files-todo.txt", RENAME_NOREPLACE) = -1 EXDEV (Invalid cross-device link) … openat(AT_FDCWD, "todo.txt", O_RDONLY|O_NOFOLLOW) = 3 openat(AT_FDCWD, "/tmp/cmd-files-todo.txt", O_WRONLY|O_CREAT|O_EXCL, 0600) = 4 write(4, "first draft\n", 12) = 12 unlinkat(AT_FDCWD, "todo.txt", 0) = 0 +++ exited with 0 +++

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:

deploy@web01 · Ubuntu 26.04 LTS
$ ls -i /tmp/cmd-files-todo.txt
3566 /tmp/cmd-files-todo.txt
$ mv /tmp/cmd-files-todo.txt ~/cmd-files/todo.txt

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).

deploy@web01 · Ubuntu 26.04 LTS
$ head -c 300M /dev/urandom > ~/cmd-files/big.bin
$ ls -lh ~/cmd-files/big.bin
-rw-rw-r-- 1 deploy deploy 300M Sep 27 08:06 /home/deploy/cmd-files/big.bin
$ mv ~/cmd-files/big.bin /tmp/cmd-files-big.bin
Hangup
# a disconnect was simulated here: mv received SIGHUP part-way through the copy
$ ls -l ~/cmd-files/big.bin /tmp/cmd-files-big.bin
-rw-rw-r-- 1 deploy deploy 314572800 Sep 27 08:06 /home/deploy/cmd-files/big.bin -rw------- 1 deploy deploy 8867840 Sep 27 08:06 /tmp/cmd-files-big.bin
$ rm /tmp/cmd-files-big.bin ~/cmd-files/big.bin

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.

deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/cmd-files rm site/index.html.bak
$ cd ~/cmd-files rmdir site-backup
rmdir: failed to remove 'site-backup': Directory not empty
$ cd ~/cmd-files rm -r site-backup

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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo touch /tmp/cmd-files-shared.txt sudo chmod 666 /tmp/cmd-files-shared.txt
$ ls -l /tmp/cmd-files-shared.txt
-rw-rw-rw- 1 root root 0 Sep 27 08:06 /tmp/cmd-files-shared.txt
$ rm /tmp/cmd-files-shared.txt
rm: cannot remove '/tmp/cmd-files-shared.txt': Operation not permitted

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 does not ask
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.
deploy@web01 · Ubuntu 26.04 LTS
$ rm -rf "${BUILD_DIR:?}"/*
-bash: line 1: BUILD_DIR: parameter null or not set

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:

deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/cmd-files ln todo.txt todo-copy.txt ls -li todo.txt todo-copy.txt
575530 -rw-rw-r-- 2 deploy deploy 12 Sep 27 08:06 todo-copy.txt 575530 -rw-rw-r-- 2 deploy deploy 12 Sep 27 08:06 todo.txt
$ cd ~/cmd-files stat todo.txt
File: todo.txt Size: 12 Blocks: 8 IO Block: 4096 regular file Device: 253,1 Inode: 575530 Links: 2 Access: (0664/-rw-rw-r--) Uid: ( 1001/ deploy) Gid: ( 1001/ deploy) Access: 2026-09-27 08:06:50.163096228 +0000 Modify: 2026-09-27 08:06:50.163096228 +0000 Change: 2026-09-27 08:06:51.950092327 +0000 Birth: 2026-09-27 08:06:50.340095841 +0000

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:

deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/cmd-files rm todo.txt cat todo-copy.txt
first draft
$ cd ~/cmd-files ln todo-copy.txt /tmp/cmd-files-hard.txt
ln: failed to create hard link 'todo-copy.txt' => '/tmp/cmd-files-hard.txt': Invalid cross-device link

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.

deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/cmd-files ln -s todo-copy.txt latest.txt ls -l latest.txt cat latest.txt
lrwxrwxrwx 1 deploy deploy 13 Sep 27 08:06 latest.txt -> todo-copy.txt first draft
$ cd ~/cmd-files mv todo-copy.txt todo-v2.txt cat latest.txt
cat: latest.txt: No such file or directory
$ cd ~/cmd-files ls -l latest.txt
lrwxrwxrwx 1 deploy deploy 13 Sep 27 08:06 latest.txt -> todo-copy.txt

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.

Two kinds of link
Hard link (ln)
Another name for the same inode
every name is equal to the original
Data lives while any name remains
rm lowers the link count
Same filesystem only
fails with Invalid cross-device link
Symbolic link (ln -s)
A small file holding a path
ls -l shows the arrow
Dangles if the target moves
the path is not updated
Can point anywhere
other filesystems, directories

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:

deploy@web01 · Ubuntu 26.04 LTS
$ ln -s ~/cmd-files/todo-v2.txt /tmp/cmd-files-link cat /tmp/cmd-files-link
first draft
$ sudo cat /tmp/cmd-files-link
cat: /tmp/cmd-files-link: Permission denied
$ cat /proc/sys/fs/protected_symlinks
1

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.

Quick check
01You start mv /home/deploy/db.dump /backup/ (a different filesystem) for a 20 GB file, and your SSH connection drops half way. What are you most likely to find?
Incorrect — mv deletes the source only after the copy has finished, so an unfinished copy never costs you the original.
Incorrect — A dropped connection sends mv the hangup signal and stops it; nothing finishes the copy for you.
Correct — Across filesystems mv copies first and deletes last, so a killed mv leaves the source whole and a partial destination file.
Incorrect — mv removes a partial copy when the copy fails with an error, but a process killed by a signal cleans up nothing.
02Earlier, ls -li showed access.log with a link count of 2. You run rm access.log. What happens to the data?
Correct — rm removed one name and lowered the link count to 1; the inode and its data remain until the last name goes.
Incorrect — rm has no trash. The data stays only because another directory entry still refers to the inode.
Incorrect — That describes a symbolic link. A hard link is a full name for the inode, not a path to the first name.
Incorrect — Nothing is copied. Both names already referred to the same inode, which is why the link count was 2.
03In /tmp, a colleague's file shows -rw-rw-rw-. You can write to it, but rm says "Operation not permitted". Why?
Incorrect — Execute permission on a file matters only for running it. Deleting a file depends on the directory, not on the file's own bits.
Correct — Removing a name is a change to the directory, and in a sticky directory only the file's owner, the directory's owner or root may do it.
Incorrect — protected_symlinks decides which symbolic links the kernel will follow. It has nothing to do with deleting ordinary files.
Incorrect — Being a tmpfs changes where the data is kept, not who may delete it. You can delete your own files in /tmp.

Related