Track 10 · Lesson 2 of 6

Build an encoder and decoder

Build encdec.sh, a Termux tool that converts text between plain, base64, base64url, hex, URL, ROT13 and binary in both directions, one small step at a time.


You will build encdec.sh, a tool that rewrites a value between plain text, base64, base64url, hex, URL percent-encoding, ROT13 and binary, in both directions. Encoding does not hide anything, it only writes the same bytes in a different alphabet, so the same tools decode as easily as they encode. Along the way you will meet the three traps that catch people: a decode that succeeds and is still wrong, a newline that breaks a round trip, and the belief that an encoded value is a secret. The script is grown a few lines at a time, and the finished file sits in one block at the end, ready to copy and run. Nothing here needs a package beyond the Termux base install.

The same conversions also live as a page you can click, at the encoder and decoder. Everything below builds it by hand.

What you'll be able to do

  • Encode and decode with base64, and check a decode rather than trusting its exit code.
  • Turn text into hex with od -An -tx1 and read it back.
  • Swap in the URL safe base64 alphabet with tr, and put the padding back to decode.
  • Percent encode and decode a value for a URL.
  • Shift letters with tr for ROT13, and say why it is a toy.
  • Show a value as eight bit groups, and read the bytes back.
  • Wire every format together behind a --to option, and read from a file or from standard input.

Step 1: Encode with base64

base64 reads standard input and writes the encoded text. Put this in a file called encdec.sh:

#!/data/data/com.termux/files/usr/bin/bash
printf '%s' "$1" | base64

Run it with a short string:

bash encdec.sh 'hello termux'
aGVsbG8gdGVybXV4

The script uses printf '%s' rather than echo, and that choice matters more than it looks. echo appends a newline to whatever it prints, and that newline is a byte like any other, so it gets encoded along with the text.

echo 'hello termux' | base64
printf '%s' 'hello termux' | base64
aGVsbG8gdGVybXV4Cg==
aGVsbG8gdGVybXV4

The first value ends in Cg==, which is the newline. The second does not. printf '%s' writes exactly the characters you gave it and no more, so it is the right tool for anything that has to survive a round trip. A newline that crept in at one end and not the other is the most common reason a value will not match after decoding.

Step 2: Decode it back, and the trap

To reverse base64, pass -d and feed the encoded text in. Replace the whole script with this:

#!/data/data/com.termux/files/usr/bin/bash
if [ "${1:-}" = "-d" ]; then
  input="$2"
  printf '%s' "$input" | base64 -d
else
  input="$1"
  printf '%s' "$input" | base64
fi

Run it both ways:

bash encdec.sh 'hello termux'
bash encdec.sh -d 'aGVsbG8gdGVybXV4'
aGVsbG8gdGVybXV4
hello termux

The round trip needs no key, because base64 is a public rule rather than a lock. That is the point the whole lesson rests on, and you can prove it by decoding a value in front of yourself. Nothing was hidden: anyone holding that string reads it in one command, exactly as you just did.

An encoded value is not a secret

Never store a password, a token or a key in base64, hex, URL form or ROT13 and call it protected. Every one of them is a public rule with no key. Use a password manager or a real secrets store instead.

Now the trap. base64 -d does not ask whether the input was base64, only whether it could be. Hand it something with characters outside the alphabet and it complains, and the exit code tells you.

printf '%s' 'notbase64!!' | base64 -d >/dev/null 2>&1; echo "exit $?"
exit 1

So always check the exit code. But a zero exit code is not proof that the value was base64. An ordinary word of the right length passes without a murmur.

printf 'password' | base64 -d >/dev/null 2>&1; echo "exit $?"
exit 0

password is eight characters, and every one of them is a valid base64 character, so the decoder accepts it and produces six bytes that mean nothing at all.

printf 'password' | base64 -d | od -An -tx1
 a5 ab 2c c2 8a dd

That is why the finished tool carries a round trip check, added in Step 10: decode the value and encode the result again, then compare. Two checks catch different faults. The exit code catches input that is not base64 at all, and the round trip catches input that was mangled on the way, because re-encoding gives something different from what you started with.

Step 3: Hex with od

Hex is another alphabet for the same bytes. od -An -tx1 prints one byte per value, in hex, and it is always available because it comes with the base system. Add these two functions to the script, above the code that uses them:

hexenc() {
  printf '%s' "$1" | od -An -tx1 | tr -s ' \n' ' ' | sed 's/^ //; s/ $//'
}

hexdec() {
  local s out="" i=0 pair byte n
  s=$(printf '%s' "$1" | tr -d ' \t\n')
  n=${#s}
  [ $(( n % 2 )) -eq 0 ] || { printf 'encdec: hex input needs an even number of digits\n' >&2; exit 1; }
  while [ "$i" -lt "$n" ]; do
    pair="${s:$i:2}"
    case "$pair" in
      *[!0-9a-fA-F]*) printf 'encdec: %s is not a hex pair\n' "$pair" >&2; exit 1 ;;
    esac
    printf -v byte '%b' "\\x${pair}"
    out="${out}${byte}"
    i=$(( i + 2 ))
  done
  printf '%s' "$out"
}

The -An drops the offset column that od normally prints, and -tx1 asks for one byte per value in hex. tr -s squeezes the runs of spaces and newlines, and the sed trims the space at each end. To read hex back, hexdec strips the spaces, checks that what is left is pairs of hex digits, and turns each pair into a byte with printf '%b'. Now introduce the --to option, so the format is chosen rather than fixed. Replace everything after the shebang with this:

fmt=base64
decode=0
text=""

while [ $# -gt 0 ]; do
  case "$1" in
    --to)
      fmt="$2"
      shift 2
      ;;
    -d)
      decode=1
      shift
      ;;
    *)
      text="$1"
      shift
      ;;
  esac
done

input="$text"

if [ "$decode" -eq 1 ]; then
  if [ "$fmt" = base64 ]; then
    out=$(printf '%s' "$input" | base64 -d)
  elif [ "$fmt" = hex ]; then
    out=$(hexdec "$input")
  fi
else
  if [ "$fmt" = base64 ]; then
    out=$(printf '%s' "$input" | base64 | tr -d '\n')
  elif [ "$fmt" = hex ]; then
    out=$(hexenc "$input")
  fi
fi
printf '%s\n' "$out"

Run it:

bash encdec.sh --to hex 'hello termux'
bash encdec.sh -d --to hex '68 65 6c 6c 6f 20 74 65 72 6d 75 78'
68 65 6c 6c 6f 20 74 65 72 6d 75 78
hello termux

Eleven bytes become eleven hex pairs. The spaces in the encoded value are ignored on the way back, so the output of hexenc feeds straight into hexdec.

Step 4: The URL safe base64 alphabet

Standard base64 uses + and /, and both of those mean something in a URL. The URL safe variant, base64url, swaps them for - and _, and drops the = padding. Add these two functions:

b64url_enc() {
  printf '%s' "$1" | base64 | tr -d '\n=' | tr '+/' '-_'
}

b64url_dec() {
  local s
  s=$(printf '%s' "$1" | tr -d ' \t\n' | tr '_-' '/+')
  while [ $(( ${#s} % 4 )) -ne 0 ]; do s="${s}="; done
  printf '%s' "$s" | base64 -d
}

Then add a base64url branch to each arm of the dispatch, next to the hex one:

  elif [ "$fmt" = base64url ]; then
    out=$(b64url_dec "$input")

and in the encode arm:

  elif [ "$fmt" = base64url ]; then
    out=$(b64url_enc "$input")

Run it on a value whose standard base64 contains a slash:

bash encdec.sh --to base64url 'hi?>'
bash encdec.sh -d --to base64url 'aGk_Pg'
aGk_Pg
hi?>

The standard base64 of hi?> is aGk/Pg==, and base64url writes it as aGk_Pg, with the slash turned into an underscore and the padding gone. Decoding has to put the padding back before base64 -d will accept it, which is what the while loop in b64url_dec does: it adds = until the length is a multiple of four.

Step 5: Percent encoding for a URL

A URL cannot carry a space or an ampersand as themselves. Percent encoding writes each byte that is not safe as a % followed by two hex digits. Add these two functions:

urlencode() {
  local out="" b ch esc
  for b in $(printf '%s' "$1" | od -An -tu1); do
    if { [ "$b" -ge 48 ] && [ "$b" -le 57 ]; } ||
       { [ "$b" -ge 65 ] && [ "$b" -le 90 ]; } ||
       { [ "$b" -ge 97 ] && [ "$b" -le 122 ]; } ||
       [ "$b" -eq 45 ] || [ "$b" -eq 46 ] || [ "$b" -eq 95 ] || [ "$b" -eq 126 ]; then
      printf -v ch "\\$(printf '%03o' "$b")"
      out="${out}${ch}"
    else
      printf -v esc '%%%02X' "$b"
      out="${out}${esc}"
    fi
  done
  printf '%s' "$out"
}

urldecode() {
  local s="$1" out="" i=0 ch pair byte n
  n=${#s}
  while [ "$i" -lt "$n" ]; do
    ch="${s:$i:1}"
    if [ "$ch" = '%' ] && [ "$i" -le $(( n - 3 )) ]; then
      pair="${s:$(( i + 1 )):2}"
      case "$pair" in
        *[!0-9a-fA-F]*) printf 'encdec: %% is not followed by two hex digits\n' >&2; exit 1 ;;
      esac
      printf -v byte '%b' "\\x${pair}"
      out="${out}${byte}"
      i=$(( i + 3 ))
    elif [ "$ch" = '+' ]; then
      out="${out} "
      i=$(( i + 1 ))
    else
      out="${out}${ch}"
      i=$(( i + 1 ))
    fi
  done
  printf '%s' "$out"
}

urlencode walks the bytes of the string with od -An -tu1. A byte in the unreserved set, which is the letters, the digits and ., -, _, ~, is written as itself; everything else becomes %XX. Because it works a byte at a time, a letter with an accent, which is two bytes in UTF-8, is encoded as two escapes. Add the url branches to the dispatch, in both arms, and run it:

bash encdec.sh --to url 'hello termux & tea'
bash encdec.sh -d --to url 'hello%20termux%20%26%20tea'
hello%20termux%20%26%20tea
hello termux & tea

A space becomes %20 and an ampersand becomes %26. urldecode reads % and the two digits after it, and turns a + into a space, which is what a form does.

Step 6: ROT13 with tr

ROT13 shifts each letter thirteen places through the alphabet. tr maps one set of characters onto another, and because thirteen is half of twenty six, the same shift undoes it. Add a rot13 branch to each arm of the dispatch, the same way as the ones before it, and run it:

bash encdec.sh --to rot13 'Hello termux'
Uryyb grezhk

Only the letters move, so the space between the two words is untouched. ROT13 hides a spoiler in a forum post. It stops nobody who wants to read your data, and it is not encryption.

Step 7: Eight bit groups

The last format is the plainest: each byte written as eight ones and zeros. Add these two functions:

tobin() {
  local out="" b bits k
  for b in $(printf '%s' "$1" | od -An -tu1); do
    bits=""
    k=7
    while [ "$k" -ge 0 ]; do
      if [ $(( (b >> k) & 1 )) -eq 1 ]; then bits="${bits}1"; else bits="${bits}0"; fi
      k=$(( k - 1 ))
    done
    if [ -z "$out" ]; then out="$bits"; else out="${out} ${bits}"; fi
  done
  printf '%s' "$out"
}

frombin() {
  local s out="" i=0 byte j bit ch n
  s=$(printf '%s' "$1" | tr -d ' \t\n')
  n=${#s}
  [ "$n" -gt 0 ] || { printf 'encdec: no binary input\n' >&2; exit 1; }
  [ $(( n % 8 )) -eq 0 ] || { printf 'encdec: binary input needs a whole number of eight bit groups\n' >&2; exit 1; }
  while [ "$i" -lt "$n" ]; do
    byte=0
    j=0
    while [ "$j" -lt 8 ]; do
      bit="${s:$(( i + j )):1}"
      case "$bit" in
        0) byte=$(( byte << 1 )) ;;
        1) byte=$(( (byte << 1) | 1 )) ;;
        *) printf 'encdec: binary input may only hold 0 and 1\n' >&2; exit 1 ;;
      esac
      j=$(( j + 1 ))
    done
    printf -v ch "\\$(printf '%03o' "$byte")"
    out="${out}${ch}"
    i=$(( i + 8 ))
  done
  printf '%s' "$out"
}

tobin shifts each byte down one bit at a time, from the seventh bit to the zeroth, and writes a 1 or a 0. frombin does the reverse, building each byte up by shifting left and setting the low bit. Add the binary branches and run it:

bash encdec.sh --to binary 'hi'
bash encdec.sh -d --to binary '01101000 01101001'
01101000 01101001
hi

h is 104, which is 01101000 in binary, and i is 105, which is 01101001.

Step 8: Tidy the dispatch into a case

The chain of if and elif has grown one branch per format, and it is now hard to read. A case says the same thing in one line per format, and it is what a shell tool normally uses. While tidying it, add a plain format, which passes the value through untouched; it is what lets the script act as a filter that leaves some values alone. Replace both dispatch arms with this:

if [ "$decode" -eq 1 ]; then
  case "$fmt" in
    plain)     out="$input" ;;
    base64)    out=$(printf '%s' "$input" | base64 -d) ;;
    base64url) out=$(b64url_dec "$input") ;;
    hex)       out=$(hexdec "$input") ;;
    url)       out=$(urldecode "$input") ;;
    rot13)     out=$(printf '%s' "$input" | tr 'A-Za-z' 'N-ZA-Mn-za-m') ;;
    binary)    out=$(frombin "$input") ;;
  esac
else
  case "$fmt" in
    plain)     out="$input" ;;
    base64)    out=$(printf '%s' "$input" | base64 | tr -d '\n') ;;
    base64url) out=$(b64url_enc "$input") ;;
    hex)       out=$(hexenc "$input") ;;
    url)       out=$(urlencode "$input") ;;
    rot13)     out=$(printf '%s' "$input" | tr 'A-Za-z' 'N-ZA-Mn-za-m') ;;
    binary)    out=$(tobin "$input") ;;
  esac
fi
printf '%s\n' "$out"

Run it on a word with an accent, which shows the bytes plainly:

bash encdec.sh --to hex 'café'
63 61 66 c3 a9

Four letters become five bytes, because the letter e with an acute accent is two bytes, c3 and a9, in UTF-8. A tool that treated the same text as latin-1 would use a single byte, e9, for that letter and print a shorter line. When a value decodes to the wrong characters, the usual cause is that one end wrote it as UTF-8 and the other read it as latin-1, and the hex output is how you tell.

Step 9: Read from a file or from standard input

A value that arrives from a pipeline, or from a file, should not have to be pasted onto the command line. Add -f to the parsing loop, and choose the input from the argument, the file or standard input. Replace the parsing block and the input="$text" line with this:

fmt=base64
decode=0
file=""
text=""
has_text=0

while [ $# -gt 0 ]; do
  case "$1" in
    --to)
      fmt="$2"
      shift 2
      ;;
    -d)
      decode=1
      shift
      ;;
    -f)
      file="$2"
      shift 2
      ;;
    *)
      text="$1"
      has_text=1
      shift
      ;;
  esac
done

if [ -n "$file" ]; then
  input=$(cat "$file")
elif [ "$has_text" -eq 1 ]; then
  input="$text"
else
  input=$(cat)
fi

Run it from a pipeline, and then from a file:

printf '%s' 'hello termux' | bash encdec.sh --to base64
aGVsbG8gdGVybXV4

With nothing on the command line, the script reads standard input, so a value flows straight in from another command. With -f it reads a named file instead, and has_text records whether a value was given so the three cases can be told apart.

Step 10: A round trip check, a usage message and clean errors

The trap from Step 2 is that base64 -d can succeed on input that was never base64. The exit code catches malformed input, but not a value that happens to be spelled in base64 characters. The tool can do the second check for you. The finishing touches are a die helper, a usage message, guards on the options that take a value, and the round trip check itself.

Add the two helpers near the top of the script, above the functions:

die() {
  printf 'encdec: %s\n' "$1" >&2
  exit 1
}

usage() {
  cat <<'EOF'
encdec.sh - move a value between plain text and several encodings.

Usage: encdec.sh [options] [TEXT]

  --to FORMAT   the format to work in (default base64):
                  plain base64 base64url hex url rot13 binary
  -d            decode instead of encode: read FORMAT, write plain text
  -c            after decoding, re-encode and report whether it round trips
  -f FILE       read the input from FILE instead of the argument or stdin
  -h            print this help and exit

The input is the TEXT argument if you give one, else the file named with -f,
else whatever arrives on standard input.

Examples:
  encdec.sh --to base64 'hello termux'
  encdec.sh -d --to base64 'aGVsbG8gdGVybXV4'
  encdec.sh --to url 'hello termux & tea'
  printf '%s' 'hello' | encdec.sh --to binary
EOF
}

The script also starts with set -u, just under the shebang, so it stops rather than carrying on with a value that was never set:

set -u

Now replace the parsing loop and the input selection from Step 9 with the finished versions. Each option that takes a value checks that the value is there, an unknown option is named, the format is checked before any work is done, and the file is checked before it is read:

fmt=base64
decode=0
check=0
file=""
text=""
has_text=0

while [ $# -gt 0 ]; do
  case "$1" in
    --to)
      [ $# -ge 2 ] || die "--to needs a format"
      fmt="$2"
      shift 2
      ;;
    -d|--decode)
      decode=1
      shift
      ;;
    -c|--check)
      check=1
      shift
      ;;
    -f|--file)
      [ $# -ge 2 ] || die "-f needs a file name"
      file="$2"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    -*)
      die "unknown option '$1' (try -h)"
      ;;
    *)
      [ "$has_text" -eq 0 ] || die "unexpected extra argument '$1'"
      text="$1"
      has_text=1
      shift
      ;;
  esac
done

case "$fmt" in
  plain|base64|base64url|hex|url|rot13|binary) ;;
  *) die "unknown format '$fmt' (use plain, base64, base64url, hex, url, rot13 or binary)" ;;
esac

if [ -n "$file" ] && [ "$has_text" -eq 1 ]; then
  die "give either a value or -f, not both"
fi

if [ -n "$file" ]; then
  [ -f "$file" ] || die "no such file: $file"
  input=$(cat "$file")
elif [ "$has_text" -eq 1 ]; then
  input="$text"
else
  input=$(cat)
fi

One more thing on the decode arm. base64 -d reports malformed input through its exit code, so the arm should check it rather than letting a failure pass as empty output. Replace the decode arm of the dispatch with this:

    base64)    out=$(printf '%s' "$input" | base64 -d) || die "input is not valid base64" ;;
    base64url) out=$(b64url_dec "$input") || die "input is not valid base64url" ;;
    hex)       out=$(hexdec "$input") || exit 1 ;;
    url)       out=$(urldecode "$input") || exit 1 ;;
    binary)    out=$(frombin "$input") || exit 1 ;;

The three that exit 1 already print their own message from inside the helper, so the outer arm only has to stop. The two base64 arms have nothing to say for themselves, so die speaks for them.

And replace the final printf '%s\n' "$out" with this block, which re-encodes the result when -c is given and compares it with what came in:

if [ "$check" -eq 1 ] && [ "$decode" -eq 1 ]; then
  case "$fmt" in
    base64)    re=$(printf '%s' "$out" | base64 | tr -d '\n') ;;
    base64url) re=$(b64url_enc "$out") ;;
    hex)       re=$(hexenc "$out") ;;
    *)         re="" ;;
  esac
  if [ -z "$re" ]; then
    printf '%s  [round trip check not supported for %s]\n' "$out" "$fmt"
  elif [ "$(printf '%s' "$re" | tr -d ' \t\n')" = "$(printf '%s' "$input" | tr -d ' \t\n')" ]; then
    printf '%s  [round trip ok]\n' "$out"
  else
    printf '%s  [round trip FAILED]\n' "$out"
  fi
else
  printf '%s\n' "$out"
fi

Run it on a value that looks like base64 but is not the canonical spelling of anything:

bash encdec.sh -d -c --to base64 'aGl='
hi  [round trip FAILED]

aGl= decodes to hi, but the canonical base64 of hi is aGk=, so the check catches the difference and reports it rather than staying silent. Be honest about the limit, though: password both decodes and re-encodes cleanly, and still means nothing. The round trip confirms that a value was not mangled, not that it was ever meant as base64. For that, you have to know where the value came from.

Try a bad format and a bad argument:

bash encdec.sh --to rot99 'hi'
bash encdec.sh -d --to hex 'zz'
bash encdec.sh --nope
encdec: unknown format 'rot99' (use plain, base64, base64url, hex, url, rot13 or binary)
encdec: zz is not a hex pair
encdec: unknown option '--nope' (try -h)

Every message names the problem and goes to standard error, and the script exits with a non zero status, so it composes properly in a pipeline.

The whole script

#!/data/data/com.termux/files/usr/bin/bash
# encdec.sh - move a value between plain text and several encodings.
#
# Runs in Termux on Android as an ordinary app: no root, no network, and
# nothing to install beyond what the base system already has.
#
# Formats: plain base64 base64url hex url rot13 binary
#
# Examples:
#     bash encdec.sh --to base64 'hello termux'
#     bash encdec.sh --to hex 'cafe'
#     bash encdec.sh -d --to base64url 'aGVsbG8gdGVybXV4'
#     printf '%s' 'hello' | bash encdec.sh --to binary

set -u

fmt=base64
decode=0
check=0
file=""
text=""
has_text=0

usage() {
  cat <<'EOF'
encdec.sh - move a value between plain text and several encodings.

Usage: encdec.sh [options] [TEXT]

  --to FORMAT   the format to work in (default base64):
                  plain base64 base64url hex url rot13 binary
  -d            decode instead of encode: read FORMAT, write plain text
  -c            after decoding, re-encode and report whether it round trips
  -f FILE       read the input from FILE instead of the argument or stdin
  -h            print this help and exit

The input is the TEXT argument if you give one, else the file named with -f,
else whatever arrives on standard input.

Examples:
  encdec.sh --to base64 'hello termux'
  encdec.sh -d --to base64 'aGVsbG8gdGVybXV4'
  encdec.sh --to url 'hello termux & tea'
  printf '%s' 'hello' | encdec.sh --to binary
EOF
}

die() {
  printf 'encdec: %s\n' "$1" >&2
  exit 1
}

# plain text -> "68 65 6c 6c 6f", one byte per value in hex.
hexenc() {
  printf '%s' "$1" | od -An -tx1 | tr -s ' \n' ' ' | sed 's/^ //; s/ $//'
}

# "68 65 6c" -> plain text. Spaces and newlines are ignored, so the output of
# hexenc feeds straight back in.
hexdec() {
  local s out="" i=0 pair byte n
  s=$(printf '%s' "$1" | tr -d ' \t\n')
  n=${#s}
  [ $(( n % 2 )) -eq 0 ] || { printf 'encdec: hex input needs an even number of digits\n' >&2; exit 1; }
  while [ "$i" -lt "$n" ]; do
    pair="${s:$i:2}"
    case "$pair" in
      *[!0-9a-fA-F]*) printf 'encdec: %s is not a hex pair\n' "$pair" >&2; exit 1 ;;
    esac
    printf -v byte '%b' "\\x${pair}"
    out="${out}${byte}"
    i=$(( i + 2 ))
  done
  printf '%s' "$out"
}

# plain text -> "01101000 01101001", one byte as eight bits.
tobin() {
  local out="" b bits k
  for b in $(printf '%s' "$1" | od -An -tu1); do
    bits=""
    k=7
    while [ "$k" -ge 0 ]; do
      if [ $(( (b >> k) & 1 )) -eq 1 ]; then bits="${bits}1"; else bits="${bits}0"; fi
      k=$(( k - 1 ))
    done
    if [ -z "$out" ]; then out="$bits"; else out="${out} ${bits}"; fi
  done
  printf '%s' "$out"
}

# "01101000 01101001" -> plain text. Spaces and newlines are ignored.
frombin() {
  local s out="" i=0 byte j bit ch n
  s=$(printf '%s' "$1" | tr -d ' \t\n')
  n=${#s}
  [ "$n" -gt 0 ] || { printf 'encdec: no binary input\n' >&2; exit 1; }
  [ $(( n % 8 )) -eq 0 ] || { printf 'encdec: binary input needs a whole number of eight bit groups\n' >&2; exit 1; }
  while [ "$i" -lt "$n" ]; do
    byte=0
    j=0
    while [ "$j" -lt 8 ]; do
      bit="${s:$(( i + j )):1}"
      case "$bit" in
        0) byte=$(( byte << 1 )) ;;
        1) byte=$(( (byte << 1) | 1 )) ;;
        *) printf 'encdec: binary input may only hold 0 and 1\n' >&2; exit 1 ;;
      esac
      j=$(( j + 1 ))
    done
    printf -v ch "\\$(printf '%03o' "$byte")"
    out="${out}${ch}"
    i=$(( i + 8 ))
  done
  printf '%s' "$out"
}

# plain text -> percent encoding. Everything outside the unreserved set is
# turned into %XX, one byte at a time, so multi byte text is handled too.
urlencode() {
  local out="" b ch esc
  for b in $(printf '%s' "$1" | od -An -tu1); do
    if { [ "$b" -ge 48 ] && [ "$b" -le 57 ]; } ||
       { [ "$b" -ge 65 ] && [ "$b" -le 90 ]; } ||
       { [ "$b" -ge 97 ] && [ "$b" -le 122 ]; } ||
       [ "$b" -eq 45 ] || [ "$b" -eq 46 ] || [ "$b" -eq 95 ] || [ "$b" -eq 126 ]; then
      printf -v ch "\\$(printf '%03o' "$b")"
      out="${out}${ch}"
    else
      printf -v esc '%%%02X' "$b"
      out="${out}${esc}"
    fi
  done
  printf '%s' "$out"
}

# percent encoding -> plain text. A plus becomes a space, as it does in a form.
urldecode() {
  local s="$1" out="" i=0 ch pair byte n
  n=${#s}
  while [ "$i" -lt "$n" ]; do
    ch="${s:$i:1}"
    if [ "$ch" = '%' ] && [ "$i" -le $(( n - 3 )) ]; then
      pair="${s:$(( i + 1 )):2}"
      case "$pair" in
        *[!0-9a-fA-F]*) printf 'encdec: %% is not followed by two hex digits\n' >&2; exit 1 ;;
      esac
      printf -v byte '%b' "\\x${pair}"
      out="${out}${byte}"
      i=$(( i + 3 ))
    elif [ "$ch" = '+' ]; then
      out="${out} "
      i=$(( i + 1 ))
    else
      out="${out}${ch}"
      i=$(( i + 1 ))
    fi
  done
  printf '%s' "$out"
}

# base64 -> base64url: the two characters that are awkward in a URL are swapped
# for two that are not, and the padding is dropped.
b64url_enc() {
  printf '%s' "$1" | base64 | tr -d '\n=' | tr '+/' '-_'
}

# base64url -> plain text. The padding is put back before the decoder sees it.
b64url_dec() {
  local s
  s=$(printf '%s' "$1" | tr -d ' \t\n' | tr '_-' '/+')
  while [ $(( ${#s} % 4 )) -ne 0 ]; do s="${s}="; done
  printf '%s' "$s" | base64 -d
}

while [ $# -gt 0 ]; do
  case "$1" in
    --to)
      [ $# -ge 2 ] || die "--to needs a format"
      fmt="$2"
      shift 2
      ;;
    -d|--decode)
      decode=1
      shift
      ;;
    -c|--check)
      check=1
      shift
      ;;
    -f|--file)
      [ $# -ge 2 ] || die "-f needs a file name"
      file="$2"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    -*)
      die "unknown option '$1' (try -h)"
      ;;
    *)
      [ "$has_text" -eq 0 ] || die "unexpected extra argument '$1'"
      text="$1"
      has_text=1
      shift
      ;;
  esac
done

case "$fmt" in
  plain|base64|base64url|hex|url|rot13|binary) ;;
  *) die "unknown format '$fmt' (use plain, base64, base64url, hex, url, rot13 or binary)" ;;
esac

if [ -n "$file" ] && [ "$has_text" -eq 1 ]; then
  die "give either a value or -f, not both"
fi

if [ -n "$file" ]; then
  [ -f "$file" ] || die "no such file: $file"
  input=$(cat "$file")
elif [ "$has_text" -eq 1 ]; then
  input="$text"
else
  input=$(cat)
fi

if [ "$decode" -eq 1 ]; then
  case "$fmt" in
    plain)     out="$input" ;;
    base64)    out=$(printf '%s' "$input" | base64 -d) || die "input is not valid base64" ;;
    base64url) out=$(b64url_dec "$input") || die "input is not valid base64url" ;;
    hex)       out=$(hexdec "$input") || exit 1 ;;
    url)       out=$(urldecode "$input") || exit 1 ;;
    rot13)     out=$(printf '%s' "$input" | tr 'A-Za-z' 'N-ZA-Mn-za-m') ;;
    binary)    out=$(frombin "$input") || exit 1 ;;
  esac
else
  case "$fmt" in
    plain)     out="$input" ;;
    base64)    out=$(printf '%s' "$input" | base64 | tr -d '\n') ;;
    base64url) out=$(b64url_enc "$input") ;;
    hex)       out=$(hexenc "$input") ;;
    url)       out=$(urlencode "$input") ;;
    rot13)     out=$(printf '%s' "$input" | tr 'A-Za-z' 'N-ZA-Mn-za-m') ;;
    binary)    out=$(tobin "$input") ;;
  esac
fi

if [ "$check" -eq 1 ] && [ "$decode" -eq 1 ]; then
  case "$fmt" in
    base64)    re=$(printf '%s' "$out" | base64 | tr -d '\n') ;;
    base64url) re=$(b64url_enc "$out") ;;
    hex)       re=$(hexenc "$out") ;;
    *)         re="" ;;
  esac
  if [ -z "$re" ]; then
    printf '%s  [round trip check not supported for %s]\n' "$out" "$fmt"
  elif [ "$(printf '%s' "$re" | tr -d ' \t\n')" = "$(printf '%s' "$input" | tr -d ' \t\n')" ]; then
    printf '%s  [round trip ok]\n' "$out"
  else
    printf '%s  [round trip FAILED]\n' "$out"
  fi
else
  printf '%s\n' "$out"
fi

Using it

Encode a short string, then decode it back:

bash encdec.sh --to base64 'hello termux'
bash encdec.sh -d --to base64 'aGVsbG8gdGVybXV4'
aGVsbG8gdGVybXV4
hello termux

Move a value through hex, and see the bytes behind an accented letter:

bash encdec.sh --to hex 'café'
bash encdec.sh -d --to hex '63 61 66 c3 a9'
63 61 66 c3 a9
café

Percent encode a value for a URL, and read it back:

bash encdec.sh --to url 'hello termux & tea'
bash encdec.sh -d --to url 'hello%20termux%20%26%20tea'
hello%20termux%20%26%20tea
hello termux & tea

The URL safe alphabet, where a slash becomes an underscore:

bash encdec.sh --to base64url 'hi?>'
aGk_Pg

A value straight out of a pipeline:

printf '%s' 'hello termux' | bash encdec.sh --to binary
01101000 01100101 01101100 01101100 01101111 00100000 01110100 01100101 01110010 01101101 01110101 01111000

And the round trip check catching a value that decodes but does not re-encode to itself:

bash encdec.sh -d -c --to base64 'aGl='
hi  [round trip FAILED]

Quick reference

CommandWhat it does
printf '%s' 'text' | base64Encode text as base64, with no trailing newline
base64 -dDecode base64 back to its bytes
echo 'text' | base64Encode with a newline included, the usual mistake
od -An -tx1One byte per column in hex, always available
printf -v byte '%b' "\\x63"Turn a hex pair into a byte
tr '+/' '-_'Swap in the URL safe base64 alphabet
tr -d '='Drop the base64 padding
tr 'A-Za-z' 'N-ZA-Mn-za-m'Apply ROT13 to letters
printf '%%%02X' 32The percent escape for a byte value
bash encdec.sh -d -c --to base64 'aGl='Decode and report whether the value round trips

Next steps

Test yourself on this

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

Start