Track 10 · Lesson 5 of 6

Build a permissions calculator

Build a shell script that converts a Unix file mode between octal and symbolic form, handles the setuid, setgid and sticky bits, and says what each bit allows.


This lesson builds the same converter as the permissions calculator, so you can do the whole job from the shell without opening it. By the end you will have a script called permtool.sh that takes a mode such as 755 or rwsr-xr-x and prints the other form, along with a plain description of what the bits allow.

You build it up a few lines at a time, running it after every step, and the finished script sits in one block at the end so you can copy the whole thing at once.

What you'll be able to do

  • Read a file mode with stat and understand the four digits.
  • Turn an octal digit into its three rwx characters.
  • Handle the special bits, including the difference between s and S.
  • Convert back from a symbolic string to octal.
  • Explain why chmod 777 is almost always wrong.

Step 1: Read a mode off a file

Start by making a file and giving it a mode with a special bit set, so there is something interesting to look at.

touch calc.sh
chmod 4755 calc.sh
stat -c '%a %n' calc.sh
4755 calc.sh

Four digits, not three. The first is the special digit and the last three are owner, group and others. ls -l shows the same thing in the other notation.

ls -l calc.sh
-rwsr-xr-x 1 u0_a123 u0_a123 0 Oct 25 08:00 calc.sh

Ten characters. The first is the file type, a dash for a regular file, and it is not a permission at all. The next nine are the three classes, three characters each. Notice the s in the owner's execute position: that is the special bit from the 4, and it is the part most converters get wrong.

Step 2: One octal digit becomes three characters

Each class is three bits, read, write and execute, worth four, two and one. A case statement turns one digit into its three characters.

cat > step2.sh <<'EOF'
digit_to_rwx() {
  case "$1" in
    0) printf '%s' '---' ;;
    1) printf '%s' '--x' ;;
    2) printf '%s' '-w-' ;;
    3) printf '%s' '-wx' ;;
    4) printf '%s' 'r--' ;;
    5) printf '%s' 'r-x' ;;
    6) printf '%s' 'rw-' ;;
    7) printf '%s' 'rwx' ;;
  esac
}
digit_to_rwx 5
echo
digit_to_rwx 7
echo
EOF
bash step2.sh
r-x
rwx

There are only eight possible digits and each one has exactly one answer, so a lookup is both the shortest and the clearest way to write this. Adding up four and two and one gives 7, which is why the digits never go above seven.

Step 3: All three digits

A mode is three of those digits in a row. 755 is owner 7, group 5 and others 5.

cat > step3.sh <<'EOF'
digit_to_rwx() {
  case "$1" in
    0) printf '%s' '---' ;; 1) printf '%s' '--x' ;; 2) printf '%s' '-w-' ;; 3) printf '%s' '-wx' ;;
    4) printf '%s' 'r--' ;; 5) printf '%s' 'r-x' ;; 6) printf '%s' 'rw-' ;; 7) printf '%s' 'rwx' ;;
  esac
}

for mode in 755 640; do
  printf '%s%s%s\n' \
    "$(digit_to_rwx "${mode:0:1}")" \
    "$(digit_to_rwx "${mode:1:1}")" \
    "$(digit_to_rwx "${mode:2:1}")"
done
EOF
bash step3.sh
rwxr-xr-x
rw-r-----

${mode:0:1} is shell parameter expansion: the substring starting at offset zero, one character long. So ${mode:1:1} is the middle digit and ${mode:2:1} is the last.

Step 4: The special digit

A four digit mode puts the special bits in front. The digit is a sum: four for setuid, two for setgid, one for sticky.

cat > step4.sh <<'EOF'
digit_to_rwx() {
  case "$1" in
    0) printf '%s' '---' ;; 1) printf '%s' '--x' ;; 2) printf '%s' '-w-' ;; 3) printf '%s' '-wx' ;;
    4) printf '%s' 'r--' ;; 5) printf '%s' 'r-x' ;; 6) printf '%s' 'rw-' ;; 7) printf '%s' 'rwx' ;;
  esac
}

mode=4755
special=${mode:0:1}
mode=${mode:1}

u=$(digit_to_rwx "${mode:0:1}")
g=$(digit_to_rwx "${mode:1:1}")
o=$(digit_to_rwx "${mode:2:1}")

printf 'special digit %s, owner %s, group %s, others %s\n' "$special" "$u" "$g" "$o"
EOF
bash step4.sh
special digit 4, owner rwx, group r-x, others r-x

The special digit is stripped off and remembered, then the remaining three are converted exactly as before. That is why the script checks the length first: a four character mode has a special digit, a three character one does not.

Step 5: s and S, t and T

This is the part worth reading twice. The special bit has no character of its own, so it borrows the execute position. If execute is also set you get a lowercase letter. If it is not, you get a capital.

cat > step5.sh <<'EOF'
digit_to_rwx() {
  case "$1" in
    0) printf '%s' '---' ;; 1) printf '%s' '--x' ;; 2) printf '%s' '-w-' ;; 3) printf '%s' '-wx' ;;
    4) printf '%s' 'r--' ;; 5) printf '%s' 'r-x' ;; 6) printf '%s' 'rw-' ;; 7) printf '%s' 'rwx' ;;
  esac
}

apply_special() {
  local special=$1 u=$2 g=$3 o=$4
  if [ $(( special & 4 )) -ne 0 ]; then
    if [ "${u:2:1}" = x ]; then u="${u:0:2}s"; else u="${u:0:2}S"; fi
  fi
  if [ $(( special & 2 )) -ne 0 ]; then
    if [ "${g:2:1}" = x ]; then g="${g:0:2}s"; else g="${g:0:2}S"; fi
  fi
  if [ $(( special & 1 )) -ne 0 ]; then
    if [ "${o:2:1}" = x ]; then o="${o:0:2}t"; else o="${o:0:2}T"; fi
  fi
  printf '%s%s%s' "$u" "$g" "$o"
}

echo "4755 -> $(apply_special 4 "$(digit_to_rwx 7)" "$(digit_to_rwx 5)" "$(digit_to_rwx 5)")"
echo "4644 -> $(apply_special 4 "$(digit_to_rwx 6)" "$(digit_to_rwx 4)" "$(digit_to_rwx 4)")"
echo "1777 -> $(apply_special 1 "$(digit_to_rwx 7)" "$(digit_to_rwx 7)" "$(digit_to_rwx 7)")"
echo "1644 -> $(apply_special 1 "$(digit_to_rwx 6)" "$(digit_to_rwx 4)" "$(digit_to_rwx 4)")"
EOF
bash step5.sh
4755 -> rwsr-xr-x
4644 -> rwSr--r--
1777 -> rwxrwxrwt
1644 -> rw-r--r-T

$(( special & 4 )) tests whether that bit is set. The bitwise AND is the natural way to ask about one bit of a number. 4644 becomes rwSr--r-- with a capital S, and that capital carries real meaning: setuid is on, but owner execute is off, so the program does not run at all. It does nothing until you add execute back. The sticky bit works the same way, with t and T.

Step 6: Back from symbolic to octal

The reverse direction walks the nine characters in three groups, adding four, two and one for each letter, and watching the execute position for a special letter.

cat > step6.sh <<'EOF'
symbolic_to_octal() {
  local s=$1
  if [ "${#s}" -eq 10 ]; then s=${s:1}; fi

  local special=0 digits='' i grp d last
  for i in 0 1 2; do
    grp=${s:i*3:3}
    d=0
    case "${grp:0:1}" in r) d=$(( d + 4 )) ;; esac
    case "${grp:1:1}" in w) d=$(( d + 2 )) ;; esac
    last=${grp:2:1}
    case "$last" in x|s|t) d=$(( d + 1 )) ;; esac
    digits="$digits$d"
    case "$last" in
      s|S)
        if [ "$i" -eq 0 ]; then special=$(( special + 4 ))
        elif [ "$i" -eq 1 ]; then special=$(( special + 2 ))
        fi ;;
      t|T) special=$(( special + 1 )) ;;
    esac
  done

  if [ "$special" -ne 0 ]; then
    printf '%s%s' "$special" "$digits"
  else
    printf '%s' "$digits"
  fi
}

for s in rwxr-xr-x rw-r--r-- rwsr-xr-x rwSr--r-- rwxrwxrwt rw-r--r-T; do
  printf '%-12s -> %s\n' "$s" "$(symbolic_to_octal "$s")"
done
EOF
bash step6.sh
rwxr-xr-x    -> 755
rw-r--r--    -> 644
rwsr-xr-x    -> 4755
rwSr--r--    -> 4644
rwxrwxrwt    -> 1777
rw-r--r-T    -> 1644

Two details matter. A ten character string from ls -l has its type letter stripped first, so pasting -rwxr-xr-x works as well as rwxr-xr-x. And a lowercase s or t counts as execute while a capital does not, which is what makes the round trip in step 5 survive being reversed here.

Step 7: Say what the bits allow

Numbers are not the useful output. A sentence is.

cat > step7.sh <<'EOF'
describe_class() {
  local label=$1 group=$2 words=''
  case "${group:0:1}" in r) words='read' ;; esac
  case "${group:1:1}" in w) words="${words:+$words, }write" ;; esac
  case "${group:2:1}" in x|s|t) words="${words:+$words, }execute" ;; esac
  if [ -z "$words" ]; then words='nothing'; fi
  printf '  %-6s %s\n' "$label" "$words"
}

symbolic=rw-r--r--
describe_class owner "${symbolic:0:3}"
describe_class group "${symbolic:3:3}"
describe_class others "${symbolic:6:3}"
EOF
bash step7.sh
  owner  read, write
  group  read
  others read

${words:+$words, } expands to nothing when words is empty and to the value plus a comma otherwise, which is how the commas land in the right places without a special case for the first one.

Step 8: Arguments, usage and errors

Now wrap it up so it behaves like a real command. A script that prints a stack trace when you mistype is not finished.

cat > step8.sh <<'EOF'
prog=${0##*/}
mode=$1

if [[ $mode =~ ^[0-7]{3,4}$ ]]; then
  octal=$mode
  symbolic="(octal_to_symbolic would run here)"
elif [[ $mode =~ ^[dlbcps-]?[rwxsStT-]{9}$ ]]; then
  octal="(symbolic_to_octal would run here)"
  symbolic=$mode
elif [ "$mode" = '-h' ] || [ "$mode" = '--help' ]; then
  printf 'Usage: %s MODE\n' "$prog"
  exit 0
elif [[ $mode == -* ]]; then
  printf '%s: unknown option %s\n' "$prog" "$mode" >&2
  exit 2
else
  printf '%s: not a valid mode: %s\n' "$prog" "$mode" >&2
  printf 'Give three or four octal digits, or nine symbolic characters.\n' >&2
  exit 1
fi

printf '%s\n' "$symbolic"
EOF

bash step8.sh 999; echo "  exit $?"
bash step8.sh -z; echo "  exit $?"
step8.sh: not a valid mode: 999
Give three or four octal digits, or nine symbolic characters.
  exit 1
step8.sh: unknown option -z
  exit 2

The order of the tests matters. The symbolic pattern has to be checked before the option test, because -rw-r--r-- starts with a dash and would otherwise be mistaken for a flag. >&2 sends the complaint to standard error, and a non-zero exit code is what lets another script notice something went wrong.

The whole script

Save this as permtool.sh and make it executable with chmod +x permtool.sh. Nothing is left out.

#!/usr/bin/env bash
# permtool.sh - convert a file mode between octal and symbolic form.

set -u

prog=${0##*/}

usage() {
  cat <<'USAGE'
Usage: permtool.sh MODE
       permtool.sh 755          octal to symbolic
       permtool.sh rwxr-xr-x    symbolic to octal

MODE is a three or four digit octal number, or a nine character
symbolic string. A leading type letter from ls -l (such as - or d)
is accepted and ignored.
USAGE
}

# Turn one octal digit into its three rwx characters.
digit_to_rwx() {
  case "$1" in
    0) printf '%s' '---' ;;
    1) printf '%s' '--x' ;;
    2) printf '%s' '-w-' ;;
    3) printf '%s' '-wx' ;;
    4) printf '%s' 'r--' ;;
    5) printf '%s' 'r-x' ;;
    6) printf '%s' 'rw-' ;;
    7) printf '%s' 'rwx' ;;
  esac
}

# Turn a three or four digit mode into the nine character symbolic string.
octal_to_symbolic() {
  local mode=$1 special=0
  if [ "${#mode}" -eq 4 ]; then
    special=${mode:0:1}
    mode=${mode:1}
  fi

  local u g o
  u=$(digit_to_rwx "${mode:0:1}")
  g=$(digit_to_rwx "${mode:1:1}")
  o=$(digit_to_rwx "${mode:2:1}")

  if [ $(( special & 4 )) -ne 0 ]; then
    if [ "${u:2:1}" = x ]; then u="${u:0:2}s"; else u="${u:0:2}S"; fi
  fi
  if [ $(( special & 2 )) -ne 0 ]; then
    if [ "${g:2:1}" = x ]; then g="${g:0:2}s"; else g="${g:0:2}S"; fi
  fi
  if [ $(( special & 1 )) -ne 0 ]; then
    if [ "${o:2:1}" = x ]; then o="${o:0:2}t"; else o="${o:0:2}T"; fi
  fi

  printf '%s%s%s' "$u" "$g" "$o"
}

# Turn a nine character symbolic string into the octal number.
symbolic_to_octal() {
  local s=$1
  if [ "${#s}" -eq 10 ]; then s=${s:1}; fi

  local special=0 digits='' i grp d last
  for i in 0 1 2; do
    grp=${s:i*3:3}
    d=0
    case "${grp:0:1}" in r) d=$(( d + 4 )) ;; esac
    case "${grp:1:1}" in w) d=$(( d + 2 )) ;; esac
    last=${grp:2:1}
    case "$last" in x|s|t) d=$(( d + 1 )) ;; esac
    digits="$digits$d"
    case "$last" in
      s|S)
        if [ "$i" -eq 0 ]; then special=$(( special + 4 ))
        elif [ "$i" -eq 1 ]; then special=$(( special + 2 ))
        fi ;;
      t|T) special=$(( special + 1 )) ;;
    esac
  done

  if [ "$special" -ne 0 ]; then
    printf '%s%s' "$special" "$digits"
  else
    printf '%s' "$digits"
  fi
}

# Print what one class of the symbolic string allows, in plain words.
describe_class() {
  local label=$1 group=$2 words=''
  case "${group:0:1}" in r) words='read' ;; esac
  case "${group:1:1}" in w) words="${words:+$words, }write" ;; esac
  case "${group:2:1}" in x|s|t) words="${words:+$words, }execute" ;; esac
  if [ -z "$words" ]; then words='nothing'; fi
  printf '  %-6s %s\n' "$label" "$words"
}

if [ $# -eq 0 ]; then
  usage >&2
  exit 2
fi

mode=$1
if [[ $mode =~ ^[0-7]{3,4}$ ]]; then
  octal=$mode
  symbolic=$(octal_to_symbolic "$mode")
elif [[ $mode =~ ^[dlbcps-]?[rwxsStT-]{9}$ ]]; then
  octal=$(symbolic_to_octal "$mode")
  symbolic=$(octal_to_symbolic "$octal")
elif [ "$mode" = '-h' ] || [ "$mode" = '--help' ]; then
  usage
  exit 0
elif [[ $mode == -* ]]; then
  printf '%s: unknown option %s\n' "$prog" "$mode" >&2
  printf 'Try %s --help for usage.\n' "$prog" >&2
  exit 2
else
  printf '%s: not a valid mode: %s\n' "$prog" "$mode" >&2
  printf 'Give three or four octal digits, or nine symbolic characters.\n' >&2
  exit 1
fi

printf '%s\n' "$symbolic"
describe_class owner "${symbolic:0:3}"
describe_class group "${symbolic:3:3}"
describe_class others "${symbolic:6:3}"

case "${symbolic:2:1}" in
  s) printf '  setuid is on, so this program runs as its owner\n' ;;
  S) printf '  setuid is on but owner execute is off, so it does nothing\n' ;;
esac
case "${symbolic:5:1}" in
  s) printf '  setgid is on, so this program runs with the file group\n' ;;
  S) printf '  setgid is on but group execute is off, so it does nothing\n' ;;
esac
case "${symbolic:8:1}" in
  t) printf '  sticky is on, so only an owner may delete files here\n' ;;
  T) printf '  sticky is on but others execute is off, so it does nothing\n' ;;
esac

Using it

An ordinary file mode:

./permtool.sh 644
rw-r--r--
  owner  read, write
  group  read
  others read

The setuid case, which is the one worth recognising on sight:

./permtool.sh 4755
rwsr-xr-x
  owner  read, write, execute
  group  read, execute
  others read, execute
  setuid is on, so this program runs as its owner

The capital S, where the special bit is set but does nothing:

./permtool.sh 4644
rwSr--r--
  owner  read, write
  group  read
  others read
  setuid is on but owner execute is off, so it does nothing

And the sticky bit, which is what keeps /tmp sane:

./permtool.sh 1777
rwxrwxrwt
  owner  read, write, execute
  group  read, write, execute
  others read, write, execute
  sticky is on, so only an owner may delete files here

It reads the other notation too, so you can paste straight from ls -l:

./permtool.sh rwsr-xr-x
rwsr-xr-x
  owner  read, write, execute
  group  read, execute
  others read, execute
  setuid is on, so this program runs as its owner

And it complains properly rather than guessing:

./permtool.sh 999
permtool.sh: not a valid mode: 999
Give three or four octal digits, or nine symbolic characters.

To audit a directory for files anyone can write to, pair it with find:

find ~/notes -perm -0002 -type f

That lists every file with the others-write bit set. If it prints anything, that is a finding.

Quick reference

PieceWhat it does
stat -c '%a %n'the mode as octal, with the file name
ls -lthe same mode as ten characters, type letter first
${mode:0:1}the first character of a string, and so on
$(( special & 4 ))true when that bit is set in the number
r w xworth four, two and one, so a class is never above seven
s and Ssetuid with execute, and setuid without it
t and Tsticky with execute, and sticky without it
>&2send the message to standard error
find -perm -0002files anyone on the machine can write to

Next steps

Test yourself on this

4 questions and 1 task where you run a real command, with the reasoning explained either way.

Start