Exit status and tests
What $? tells you, exit codes 1/2/126/127/128+n, and [[ ]] vs [ ].
tar -xzf bo-tests.tar.gz, which creates bo-tests/. SHA-256: bb5b2839738864a6b936db2e82bbd01615155e271c849d08841a248a182ef0feEvery decision a script makes rests on two things: the exit status of the command it just ran, and tests that compare values or check files. This lesson teaches you to read both. You will keep "no match" apart from "could not run", recognise the numbers Bash sets itself (126, 127 and 128 plus a signal number), write string, number and file tests with [ ] and [[ ]], and check a number that comes from outside the script before any arithmetic touches it, because Bash arithmetic can run a command hidden in the value. The examples run in ~/bash-ops/tests, next to a small SSH log. Save it there as auth.log: it has three failed password attempts, one accepted key login and no Invalid user line, and the results below depend on exactly those lines.
2026-09-27T09:12:44.103221+00:00 web01 sshd-session[2211]: Failed password for invalid user admin from 203.0.113.7 port 50214 ssh22026-09-27T09:12:47.591870+00:00 web01 sshd-session[2211]: Failed password for invalid user admin from 203.0.113.7 port 50214 ssh22026-09-27T09:13:02.004518+00:00 web01 sshd-session[2230]: Accepted publickey for deploy from 198.51.100.24 port 41822 ssh2: ED25519 SHA256:3xAmPlEfInGeRpRiNtOnLyFoRtHeLaBxxxxxxxxxxx2026-09-27T09:13:02.118902+00:00 web01 sshd-session[2230]: pam_unix(sshd:session): session opened for user deploy(uid=1001) by deploy(uid=0)2026-09-27T09:15:31.662045+00:00 web01 sshd-session[2254]: Failed password for root from 203.0.113.7 port 50388 ssh22026-09-27T09:15:33.870311+00:00 web01 sshd-session[2254]: Connection closed by authenticating user root 203.0.113.7 port 50388 [preauth]
Every command ends with a status
The Linux essentials lesson "Pipes, redirection and exit status" introduced $? (0 to 255, 0 for success) and grep -q, whose status is the whole answer: 0 a line matched, 1 the file was read and nothing matched, 2 grep could not do its job, for example because the file is missing. In a script the difference between 1 and 2 matters. Treat them the same and a failed-login check reports "no failed logins" on the morning the log was rotated away: a finding hidden as a clean result. Keep the difference whenever a command gives you one.
$? only ever holds the status of the most recent command. Anything you run in between replaces it, even an echo:
In the first step grep found no root session and returned 1, but the echo in between succeeded, so $? said 0 by the time it was printed. In the second step rc=$? saved the status on the very next line, and the saved value survives whatever runs later. Save $? immediately, or test the command directly in an if (next lesson) so there is nothing to save.
Statuses the shell sets itself
Some statuses do not come from the program at all, because the program never ran or never finished. Bash fills them in:
126 means Bash found the file but could not execute it: the printf line created notes.txt as an ordinary text file, without the execute bit. 127 means no command of that name exists, which in a script is almost always a typo or a tool that is not installed on this machine.
The third kind comes from signals. As the Linux essentials lesson "Signals and job control" showed, a process killed by a signal has no exit status of its own, so Bash reports 128 plus the signal number: 143 for SIGTERM (15), 137 for SIGKILL (9) and 130 for SIGINT (2, Ctrl-C). In a job log, a step that "exited 137" without printing an error of its own was most likely killed, for example by the kernel's out-of-memory killer, so look for what killed it before you look at the script. The lesson "Traps, signals and cleanup" shows how a script can react to these signals.
An exit status is a single byte, so it only holds 0 to 255. Larger numbers are reduced modulo 256:
exit 256 reports success. A script that ends with exit $errors reports success when there were exactly 256 errors, so exit with a fixed status you have documented and print the count instead.
Ask a question: test, [ ] and [[ ]]
A test is a command that checks one condition and returns 0 when it is true and 1 when it is false, so tests plug into everything that reads exit statuses. The POSIX test command has two spellings, test EXPR and [ EXPR ]; they are the same command, and [ insists that its last argument is ]. Here are the string tests:
= means equal and != means different; -z is true for an empty string (zero length) and -n for a non-empty one. Numbers are compared with letter operators, because < and > already mean redirection to the shell: -eq, -ne, -lt (less than), -le (less or equal), -gt and -ge:
File tests ask about a path: -e exists, -f is a regular file, -d is a directory, -r, -w and -x whether you may read, write or execute it, and -s whether it exists and is not empty. auth.log exists, is a file and is readable; notes.txt is not executable; auth.log.1 does not exist, so it is not non-empty either. Prefer -r to -e before reading a file: it answers the question the script actually has.
Bash has a second form, [[ ]]. The difference shows in how the shell treats them:
test and [ are builtins: commands like any other, whose arguments go through word splitting and filename expansion before the command sees them. [[ is a keyword, part of Bash's grammar, so Bash reads what is between the brackets itself and does not split variables there. The difference matters as soon as a value contains a space or is empty:
Unquoted, $reply became two words, so [ received yes please = yes and gave up with status 2. The empty case is worse, because it fails silently: with $path empty, the command was just [ -n ]. A test with a single argument is true when that argument is a non-empty string, and -n is one, so "is path non-empty?" answered yes. Quoted, both tests gave the right answer. With [ ], every variable goes in double quotes. [[ ]] does not have these problems:
Unquoted variables behave correctly inside [[ ]], but two operators work differently there. On the right of == (or =), an unquoted value is a pattern, so web* matched web01; quote it when you mean the literal text. And < compares strings in sort order, so "10" < "9" is true, like "apple" before "banana". Use -lt for numbers.
Which to use: in a Bash script, [[ ]], which is harder to get wrong. In a #!/bin/sh script, [ ] with every variable quoted, because sh (Dash) has no [[, as the first lesson showed. This course writes [[ ]] from here on.
Numbers from outside the script
Scripts get numbers from places they do not control: arguments, environment variables, config files, another command's output. This script takes a limit as its first argument and raises an alert when auth.log has more failed passwords than that. It has a flaw, marked in its header, which the rest of this section exposes and then fixes:
#!/usr/bin/env bash# Usage: check-fails.sh LIMIT LOGFILE# Warns when LOGFILE has more than LIMIT failed SSH password attempts.# Exit status: 0 at or under the limit, 1 over it, 2 wrong arguments.# UNSAFE: LIMIT reaches an arithmetic test without being checked first.if [[ $# -ne 2 ]]; thenprintf 'usage: check-fails.sh LIMIT LOGFILE\n' >&2exit 2filimit=$1log=$2count=$(grep -c 'Failed password' -- "$log")if [[ $count -gt $limit ]]; thenprintf 'ALERT: %s failed logins (limit %s)\n' "$count" "$limit"exit 1fiprintf 'ok: %s failed logins (limit %s)\n' "$count" "$limit"
grep -c prints the number of matching lines. The -- tells grep that no options follow, so a log name starting with - is still read as a name (the lesson "Quoting, word splitting and globbing" explains why that matters). The status follows the convention of the header: 0 under the limit, 1 over it, 2 wrong arguments. With honest input it works:
Now give it a limit that is not a number. The value below contains $( ), command substitution from the lesson on variables. The command inside is harmless and only prints a word, but it could be any command:
The word injected was printed by code that arrived as data. Inside [[ ]], the operands of -gt, -eq and the other number operators are arithmetic expressions, and so is everything inside (( )). Bash evaluates them as small programs: a name is read as a variable, and a[...] is an element of an array whose index is itself evaluated, which runs the $( ) inside it. The command ran with the script's user and permissions. Then a turned out to be empty, empty counts as 0, and 3 is greater than 0, so the script also raised a false alert. The same happens at the prompt, and [ ] behaves differently:
[[ -eq ]] and (( )) both ran the command. [ ] requires a plain integer, refused the value and returned 2. That is not a fix on its own, though: inside an if, status 2 is simply "not true", so the unsafe script rewritten with [ ] would print ok for any value it cannot read. Two smaller surprises come from the same rule:
A plain word is read as a variable name. count happens to be the script's own variable, holding 3, so the test became "3 is greater than 3", and the check passes no matter how many failed logins the log holds. A leading zero makes a number octal (base 8), so 010 is 8 and 08 is an error, and an error in a test counts as "false". Numbers are 64-bit, so a 20-digit value silently wraps round to a different number.
The fix is to validate the value before any arithmetic sees it, with a test that does not evaluate it: a regular expression match. [[ $value =~ regex ]] (Bash-only) is true when the regular expression matches. In ^(0|[1-9][0-9]*)$, ^ and $ anchor the pattern to the whole value, and the middle allows either a single 0 or a digit from 1 to 9 followed by any digits. Leave the pattern unquoted, because a quoted right-hand side is compared as literal text. The fixed script:
#!/usr/bin/env bash# Usage: check-fails.sh LIMIT LOGFILE# Warns when LOGFILE has more than LIMIT failed SSH password attempts.# Exit status: 0 at or under the limit, 1 over it, 2 wrong arguments.if [[ $# -ne 2 ]]; thenprintf 'usage: check-fails.sh LIMIT LOGFILE\n' >&2exit 2filimit=$1log=$2# Only a plain decimal number may reach -gt: Bash evaluates the operands of an# arithmetic test as expressions, and an expression can run commands. Leading# zeros are refused too, because Bash reads 010 as octal (8) and rejects 08.if [[ ! $limit =~ ^(0|[1-9][0-9]*)$ ]]; thenprintf 'check-fails: LIMIT must be a whole number, got: %s\n' "$limit" >&2exit 2fiif [[ ! -r $log ]]; thenprintf 'check-fails: cannot read %s\n' "$log" >&2exit 2ficount=$(grep -c 'Failed password' -- "$log")if [[ $count -gt $limit ]]; thenprintf 'ALERT: %s failed logins (limit %s)\n' "$count" "$limit"exit 1fiprintf 'ok: %s failed logins (limit %s)\n' "$count" "$limit"
Honest input behaves exactly as before. The injected value, the variable name and 08 are all refused with a message on standard error and status 2, and injected was never printed, because nothing evaluated the value. The new -r check also turns a missing log into a clear error: without it, grep would fail, count would be empty, and an empty count is 0, which is "ok".
-eq, -gt, $(( )) or (( )) from outside the script (arguments, environment variables, files, command output, read) must pass a digits-only check first. Validate once, at the point where the value enters, and exit 2 with a message when it fails. Switching to [ ] is not a substitute: it refuses the value, but the script carries on as if the answer were "false".Try this
Write port-ok.sh PORT in a new directory. It prints a usage message and exits 2 unless it gets exactly one argument, then accepts only TCP port numbers from 1 to 65535: validate with [[ $port =~ ^[1-9][0-9]{0,4}$ ]] (no leading zero, at most five digits) and only then test -gt 65535. Errors go to standard error with status 2; a good port prints port 22 ok and exits 0. Run it with each of these and predict the result first:
1. 22: port 22 ok, status 0.
2. 70000: port-ok: out of range: 70000, status 2. It passed the pattern and failed the range check.
3. 022: refused as not a port number, status 2, because of the leading zero.
4. 'a[$(echo injected >&2)]' (in single quotes): not a port number, status 2, and the word injected must not appear.
5. Twenty 9s: not a port number, status 2. The five-digit cap refuses it before arithmetic can wrap it round.
Takeaway
Read exit statuses as specific answers, and save $? on the very next line if you need it later. Use [[ ]] in Bash scripts and quote every variable inside [ ], and let no number from outside the script reach an arithmetic test before a digits-only check has passed.
if (( $1 > 100 )); then rotate_logs; fi. The argument is built by another team's tool from user input. What is the real risk, and what fixes it?(( x == 0 )) ran the command from a variable.a[$(cmd)] makes Bash run cmd while evaluating the index. Validating with [[ $1 =~ ^(0|[1-9][0-9]*)$ ]] first means only a plain decimal number is ever evaluated.[ ] does not run the payload, but it returns 2, which if treats as false, so the script carries on as though the value were under the limit. It still needs validation.$( ) needs no separator, which is why a blocklist of characters fails and a digits-only allowlist works.if [ -n $DEPLOY_TAG ]; then deploy "$DEPLOY_TAG"; else echo "no tag" >&2; exit 2; fi. When DEPLOY_TAG is empty, it calls deploy with an empty tag. Why?[ -n ]. A single argument is true when it is a non-empty string, and -n is one. Quote it: [ -n "$DEPLOY_TAG" ], or use [[ -n $DEPLOY_TAG ]].-n tests for a non-empty string; it has nothing to do with numbers. The test went wrong because it never received the variable at all.[ -n "" ] (quoted) is correctly false. No rule makes tests on unset variables true.[ never looks up variables; the shell expands them before [ runs. The value was empty, and the lack of quotes made it vanish.