Files, Permissions & the Linux Shell — One Tree, Three Digits, Many Pipes
What you'll learn
- How Linux arranges every file in one tree that starts at
/, and how absolute and relative paths are resolved - Who you are to the kernel: users, groups, UIDs and GIDs
- What the permission bits
rwxmean for files and for directories, in symbolic and octal form, and how the kernel picks one class to check - How redirection (
>,<,2>&1) and pipes (|) rewire a program's input and output - The text tools that make the shell productive:
sort,uniq,cutandawk
You will build a path resolver in JavaScript, a permission checker in C and a shell pipeline that reports on an /etc/passwd file.
One tree, rooted at / 🌳
Windows gives every disk a letter (C:, D:). Linux has one tree. It starts at the root directory /, and every disk, USB stick or network share is mounted onto some directory of that tree. The usual layout follows the Filesystem Hierarchy Standard (FHS):
/
├── bin/, sbin/, usr/ programs (on most current distros /bin is a link to /usr/bin)
├── etc/ system configuration: passwd, group, hosts, ssh/
├── home/ one directory per user: /home/ana, /home/omar
├── root/ the root user's home
├── tmp/ temporary files, writable by everyone
├── var/ data that changes: log/, cache/, lib/
├── dev/ device files: /dev/null, /dev/sda
└── proc/, sys/ virtual files the kernel generates on the fly
The last line hints at a Unix idea: almost everything is a file. Devices, kernel settings and even other processes (/proc/<pid>/) are opened and read with the same calls as a text file.
Absolute and relative paths
- An absolute path starts with
/and is read from the root:/home/ana/notes.txt - A relative path does not start with
/and is read from the process's current working directory (pwdprints it): from/home/ana,notes.txtmeans/home/ana/notes.txt - Every directory contains two special names:
.(itself) and..(its parent). In/,..is/again - Repeated slashes count as one:
/etc//sshis/etc/ssh
POSIX calls this pathname resolution: the kernel walks the path one component at a time, starting at / or at the current directory. (It also follows symbolic links on the way; you will meet those in the file-systems lesson.)
Two details trip up beginners:
~is not a path the kernel understands. The shell replaces a leading~with your home directory before the program runs (tilde expansion). A C program that opens"~/notes.txt"looks for a directory literally named~.- The current directory belongs to each process, and a child gets a copy at
fork. That is whycdmust be a shell built-in: an externalcdprogram would change only its own directory and then exit.
Watch the walk with a stack:
Each component either pushes a name, pops one (..) or changes nothing (. and the empty parts that // and a trailing / produce). With more .. than levels the stack simply stays empty, which is the root. Try your own paths or another cwd.
# Resolve a path the way the kernel walks it: one component at a time, with a stack.
def resolve(cwd, path):
full = path if path.startswith("/") else cwd + "/" + path
stack = []
for part in full.split("/"):
if part in ("", "."):
continue # "a//b" and "a/./b" both mean "a/b"
if part == "..":
if stack:
stack.pop() # go up one level (the root's parent is the root)
else:
stack.append(part)
print(f" {part:<8} stack = {stack}")
return "/" + "/".join(stack)
cwd = "/home/ana/projects"
for path in ["../photos/./cat.png", "/etc//ssh/", "../../../../tmp"]:
print(f"{path} (from {cwd})")
print(" =>", resolve(cwd, path))
Lab exercise 1
JavaScript (Node.js)Graded in Code LabWrite a path resolver that turns any path into a clean absolute path, the way realpath -m -s does (no symbolic links are followed and nothing needs to exist).
Input:
- Line 1: the current working directory (an absolute path)
- Line 2: the home directory (an absolute path)
- Then one path per line until the end of input (skip blank lines)
Rules:
- A path that is exactly
~or starts with~/begins with the home directory instead of the~(tilde expansion). Any other~is an ordinary character. - A path that does not start with
/is relative to the current directory. - Empty components (from
//) and.are skipped;..removes the previous component, and at the root it stays at the root.
Output: one line per path: <path> -> <resolved>. The result never ends with /, except the root itself.
Example:
/home/ana/projects
/home/ana
notes.txt
../photos/./cat.png
/etc//ssh/
~/.bashrc
../../../../..
prints
notes.txt -> /home/ana/projects/notes.txt
../photos/./cat.png -> /home/ana/photos/cat.png
/etc//ssh/ -> /etc/ssh
~/.bashrc -> /home/ana/.bashrc
../../../../.. -> /
- Example from the prompt
- Tilde is special only at the start
- Working from the root, with blank lines
- Absolute paths ignore the current directory
- No paths: nothing to print
Example run
- Input
/home/ana/projects /home/ana notes.txt ../photos/./cat.png /etc//ssh/ ~/.bashrc ../../../../..
- Expected output
notes.txt -> /home/ana/projects/notes.txt ../photos/./cat.png -> /home/ana/photos/cat.png /etc//ssh/ -> /etc/ssh ~/.bashrc -> /home/ana/.bashrc ../../../../.. -> /
const lines = require("fs").readFileSync(0, "utf8").split("\n");
const cwd = (lines[0] || "/").trim();
const home = (lines[1] || "/").trim();
function resolve(cwd, home, path) {
// TODO 1: replace a leading "~" (alone, or followed by "/") with the home directory
// TODO 2: if the path is relative, put the current directory in front of it
// TODO 3: split on "/" and walk the parts with a stack:
// skip "" and ".", pop on "..", push anything else
// TODO 4: join the stack back into an absolute path ("/" when it is empty)
return path;
}More lines in Code Lab: 6
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Users, groups and permission bits 🔐
To the kernel you are not a name but a number. Every process runs with a user ID (UID) and a group ID (GID), plus a list of supplementary groups. The names live in two text files:
/etc/passwd: one account per line,name:password:UID:GID:GECOS:home:shell(the password field is justx; the hashes live in/etc/shadow, which only root can read)/etc/group:name:password:GID:members
id shows your identity, for example uid=1000(ana) gid=1000(ana) groups=1000(ana),27(sudo). UID 0 is root, the superuser. On most current distributions regular accounts start at UID 1000.
Reading ls -l
-rw-r----- 1 ana staff 2048 Mar 3 10:15 report.txt
│└┬┘└┬┘└┬┘ │ │
│ │ │ │ │ └── group owner
│ │ │ │ └────── user owner
│ │ │ └─────────── other: --- (nothing)
│ │ └────────────── group: r-- (read)
│ └───────────────── owner: rw- (read, write)
└─────────────────── type: - file, d directory, l symbolic link
The three letters mean different things for files and for directories:
- r: read the file's bytes / list the names in the directory
- w: change the file / create, delete or rename entries in the directory (so deleting a file depends on the directory's
w, not the file's) - x: run the file as a program / enter the directory and reach names inside it (
cd, or any path that passes through it)
Octal: three digits, one per class
Each class is three bits, so it fits in one octal digit: r = 4, w = 2, x = 1. Add them up: rw- = 6, r-x = 5, r-- = 4. So -rw-r----- is 640 and rwxr-xr-x is 755.
chmod 640 report.txt # owner rw, group r, other nothing
chmod u+x,o-r deploy.sh # symbolic: add x for the owner, remove r for others
chown ana:staff report.txt # change owner and group (giving a file away needs root)
New files do not start at 777. A program usually asks for 666 (files) or 777 (directories) and the process's umask removes bits: with the common umask 022, files become 644 and directories 755.
How the kernel decides
When a process opens a file, the kernel picks exactly one class, in this order (see path_resolution(7)):
- If the process's UID is the file's owner, use the owner bits
- Otherwise, if the file's group is one of the process's groups, use the group bits
- Otherwise use the other bits
Only that class counts. A file with mode 075 denies its own owner, even though everybody else may read it. Root skips these checks for reading and writing, but may execute a file only if at least one x bit is set.
Three special bits sit in front of the usual nine: setuid (4000: the program runs with the file owner's UID, which is how passwd can update /etc/shadow), setgid (2000) and the sticky bit (1000: in a directory such as /tmp, mode 1777, a file can be deleted only by its owner, the directory's owner or root). The security lesson comes back to them.
Each octal digit is three bits: 7 = rwx, 5 = r-x, 4 = r--. The umask is removed bit by bit (666 AND NOT 022 = 644), which is why a umask never adds a permission. Try 0o027, a common choice on servers.
// Permission bits: three octal digits, one per class (owner, group, other).
function symbolic(mode) {
const letters = "rwxrwxrwx";
let out = "";
for (let bit = 8; bit >= 0; bit--) out += mode & (1 << bit) ? letters[8 - bit] : "-";
return out;
}
for (const mode of [0o755, 0o644, 0o600, 0o750, 0o777]) {
console.log(mode.toString(8).padStart(3, "0"), symbolic(mode));
}
// New files start from 666 (directories from 777); the umask removes bits.
for (const umask of [0o022, 0o077, 0o002]) {
const file = 0o666 & ~umask;
const dir = 0o777 & ~umask;
console.log("umask " + umask.toString(8).padStart(3, "0") + ": file " + symbolic(file) + " dir " + symbolic(dir));
}
Lab exercise 2
CGraded in Code LabWrite the kernel's permission check for one file.
Input:
- Line 1:
<mode> <owner> <group>: the permission bits as octal digits (0to777, no special bits), the owning user and the owning group - Then one request per line:
<user> <groups> <op>, where<groups>is the comma-separated list of every group the user belongs to and<op>isr,worx
Output:
- First the mode in symbolic form as
ls -lshows it for a regular file, for example-rw-r----- - Then one line per request:
<user> <op>: allowed (<class>)or<user> <op>: denied (<class>)
Rules:
- The user named
roothas classroot:randware always allowed,xonly if at least one of the threexbits is set - Otherwise, if the user is the owner the class is
owner; else if the file's group is in the user's list it isgroup; else it isother - Only the bit of that one class is checked
Example:
640 ana staff
ana ana r
bob bob,staff w
carol carol r
root root x
prints
-rw-r-----
ana r: allowed (owner)
bob w: denied (group)
carol r: denied (other)
root x: denied (root)
- Example from the prompt
- The owner class applies even when it denies
- Supplementary groups count
- Root and the execute bits
- Mode only, no requests
Example run
- Input
640 ana staff ana ana r bob bob,staff w carol carol r root root x
- Expected output
-rw-r----- ana r: allowed (owner) bob w: denied (group) carol r: denied (other) root x: denied (root)
#include <stdio.h>
#include <string.h>
/* Returns 1 if the comma-separated list contains this group (ready to use). */
int in_groups(const char *list, const char *group) {
size_t n = strlen(group);
const char *p = list;
while (*p) {
const char *end = strchr(p, ',');
size_t len = end ? (size_t)(end - p) : strlen(p);
if (len == n && strncmp(p, group, n) == 0) return 1;
if (!end) break;More lines in Code Lab: 25
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Redirection and pipes 🔀
Every process starts with three open file descriptors: 0 = stdin, 1 = stdout, 2 = stderr. A program simply reads fd 0 and writes fds 1 and 2; the shell decides what they are connected to before it execs the program:
sort names.txt > sorted.txt # fd 1 goes to a file (created or truncated)
echo done >> build.log # append instead of truncate
wc -l < names.txt # fd 0 comes from a file
make 2> errors.txt # only stderr goes to the file
make > all.txt 2>&1 # stdout to the file, then stderr to wherever stdout points now
find / -name '*.conf' 2>/dev/null # throw the "Permission denied" noise away
Order matters: 2>&1 copies whatever fd 1 points to at that moment. cmd > all.txt 2>&1 sends both streams to the file, while cmd 2>&1 > all.txt sends stderr to the terminal and only stdout to the file.
A pipe | connects the stdout of one command to the stdin of the next. The shell calls pipe() to get a kernel buffer with a write end and a read end, forks once per command and wires the ends to fd 1 and fd 0. All the commands of a pipeline run at the same time: a writer that fills the buffer (64 KiB by default on Linux, see pipe(7)) blocks until the reader catches up, and a reader blocks until data arrives.
ps -e --no-headers | wc -l # how many processes (ps and wc included)? two programs, one pipe
The exit status of a pipeline is the status of its last command, so a failure on the left can go unnoticed. In Bash, set -o pipefail makes the pipeline fail if any of its commands fails.
The text toolbox: sort, uniq, cut, awk 🧰
Unix tools each do one job and talk through plain lines of text, so pipes can combine them:
cut -d: -f1,7 /etc/passwd: keep fields 1 and 7, using:as the separatorsort: sort lines;-nnumeric,-rreverse,-t:field separator,-k3,3nsort by field 3 as a number. PrefixLC_ALL=Cfor plain byte order, the same on every machineuniq: drop adjacent duplicate lines;-cprefixes each line with its count. Because only neighbours are merged, you almost alwayssortfirstawk: a small language for columns. It splits each line into fields$1,$2, ... (-F:changes the separator) and runspattern { action }for every matching line.NRis the line number,NFthe number of fields, and anEND { }block runs after the last line
awk -F: '$3 >= 1000 { print $1 }' /etc/passwd # names of regular users
awk '{ sum += $2 } END { print sum }' sizes.txt # add up column 2
The most famous pipeline counts things. Which IP addresses hit a web server most often?
cut -d' ' -f1 access.log | sort | uniq -c | sort -rn | head -3
42 203.0.113.9
17 198.51.100.4
5 192.0.2.77
cut extracts the first field, sort groups equal lines, uniq -c counts each group, sort -rn puts the biggest count first and head keeps the top three. (The addresses come from the documentation ranges reserved by RFC 5737.)
Now build the same kind of pipeline over a passwd file.
Lab exercise 3
BashGraded in Code LabWrite a shell pipeline that reports on a file in /etc/passwd format, read from stdin.
Input: zero or more lines name:password:UID:GID:GECOS:home:shell (seven fields separated by :; the GECOS field may contain spaces and commas).
Output:
- One line
<count> <shell>per login shell (field 7), the most used shell first; shells with the same count are sorted by name (byte order). Aspasswd(5)says, an empty shell field means/bin/sh. - A last line
humans: <names>with every account whose UID is 1000 or more, except65534(nobody), in input order and separated by single spaces, orhumans: none.
Example:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
ana:x:1000:1000:Ana Silva,,,:/home/ana:/bin/bash
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
prints
2 /bin/bash
2 /usr/sbin/nologin
humans: ana
- Example from the prompt
- Ties, an empty shell field and nobody
- Empty input
- Only system accounts
- UIDs compare as numbers: 999 is a system account
Example run
- Input
root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin ana:x:1000:1000:Ana Silva,,,:/home/ana:/bin/bash nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
- Expected output
2 /bin/bash 2 /usr/sbin/nologin humans: ana
#!/usr/bin/env bash # Read the whole passwd-style file from stdin. data=$(cat) # TODO 1: print "<count> <shell>" for every login shell (field 7), most used first, # ties sorted by shell name. An empty shell field counts as /bin/sh. # Idea: awk -F: to print field 7, then sort | uniq -c, reshape with awk, # and sort again with: LC_ALL=C sort -k1,1nr -k2,2 # TODO 2: print "humans:" followed by the names of accounts with UID >= 1000 # (except 65534, nobody), in input order, or "humans: none". echo "humans: none"
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
Your current directory is /home/ana/projects. Which file does the relative path ../../omar/./notes.txt name?
Question 2 of 5
A file owned by ana has mode 044 (----r--r--). Can ana read it?
Question 3 of 5
Which permissions do you need to delete /data/report.txt?
Question 4 of 5
What is the difference between cmd > out.txt 2>&1 and cmd 2>&1 > out.txt?
Question 5 of 5
Why do pipelines almost always run sort before uniq -c?
Summary
- Linux has one tree rooted at
/; other disks are mounted into it. Absolute paths start at/, relative paths at the process's current directory;.,..and repeated slashes are resolved component by component ~is expanded by the shell, andcdis a built-in because the current directory belongs to each process- The kernel knows UIDs and GIDs. Permission bits
rwxfor owner, group and other fit in three octal digits; on a directory, w means create/delete entries and x means enter - The kernel checks exactly one class (owner, else group, else other); root bypasses read and write checks, and execute needs at least one x bit
- Redirection rewires fds 0, 1 and 2 before
exec(2>&1order matters); a pipe connects two concurrently running processes through a kernel buffer cut,sort,uniq -candawkturn text into answers; you wrote a path resolver, a permission checker and a passwd report
Practice: on a Linux machine run ls -l /etc/passwd /etc/shadow, id, umask and stat -c '%a %U %G %n' ~, then rebuild the IP counting pipeline on any log file you own. Next: concurrency and locks, and then file systems, where you will see how a path turns into an inode and disk blocks.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1JavaScript (Node.js)Not passed yet Try in Code Lab
- Lab exercise 2CNot passed yet Try in Code Lab
- Lab exercise 3BashNot passed yet Try in Code Lab