r/bash•
Schreq

Shell Option Parsing in Just 15 SLOC (Excluding Blank Lines & Comments)

A while ago I posted boilerplate for portable scripts but it's rather complex. This time I wanted to see how small and simple I can make option parsing in a portable manner, using getopts, and only including the bare minimum for a functional script, while trying to stay close to the POSIX utility conventions. That means no long-options, no detailed help output with descriptions for all options but just a simple usage line, etc.

To use this, we only have to adapt the couple variables at the top to our script. Options in the options variable are required to be space separated. Options requiring a parameter are marked with a trailing ':' (colon). Only [A-Za-z0-9] are allowed as options.

The script:

#!/bin/sh

arg0=${0##*/}
options="a b: h V"
usage="[-ahV] [-b param] [operand...]"
version="getops example version 0.1"

die() { printf '%s: %s\n' "$arg0" "$*" >&2; exit 1; }
usage() { printf 'usage: %s %s\n' "$arg0" "$usage"; exit; }
version() { printf '%s\n' "$version"; exit; }

# Make sure the environment is not polluting our option variables.
for o in $options; do unset "opt_${o%:}"; done

# Parse options. We only set variables here and bail on usage errors. The
# option character prefixed by "opt_" and "arg_" will be used as variable
# names. opt_* will hold the amount of occurences (empty if 0) and arg_*
# will hold the option argument (parameter).
while getopts ":$options" o; do case $o in
	:) die "option '$OPTARG' requires a parameter" ;;
	\?) die "unrecognized option '$OPTARG'" ;;
	*) eval "opt_$o=\$((opt_$o + 1)) arg_$o=\$OPTARG" ;;
esac; done

# Shift until only operands remain.
shift "$((OPTIND - 1))"

Besides usage errors, which are handled, the script only parses the option. To actually handle them, we could do something like this:

[ "$opt_h" ] && usage
[ "$opt_V" ] && version
[ "$opt_a" ] && printf 'Option a present %d time(s)\n' "$opt_a"
[ "$opt_b" ] && printf 'Option b parameter: %s\n' "$arg_b"

# Remaining operands.
for o do
 	printf '%s\n' "$o"
 done

Posted Dec 26, 2021, 2:12 PM UTC

128
shellmachine•

Looks okay to me, but getopts makes code unnecessarily hard to read. I would simply use a case-construct for this, as in this example: https://codeberg.org/armin/salis/src/branch/master/salis

2
SchreqOP•

but getopts makes code unnecessarily hard to read.

How so? I'd argue a giant case-statement is less readable than one line and action per option:

[ "$opt_h" ] && usage
[ "$opt_V" ] && version
[ "$opt_a" ] && printf 'Option a present %d time(s)\n' "$opt_a"
[ "$opt_b" ] && printf 'Option b parameter: %s\n' "$arg_b"

Option parsing done like in the linked script is exactly the type I'm not a fan of, because it usually has quite a few problems:

It will exit with an error when the last option misses it's mandatory parameter, because of shifting when there are no positional parameters left (bash doesn't error out but most other POSIX compliant shells do). Sure, we could simply check before shifting but that means even more code, repeated for every option which requires a parameter.

It does not allow combining short-options and does not handle -- to signal the end of the options (to be fair, that can easily be added but is yet more code).

It doesn't properly separate option parsing and handling. After a -h, all following usage errors are simply ignored. Sure, that's only a minor thing but most serious core utilities do it differently.

So all in all it's more code for less correct behaviour.

5
shellmachine•

Well I refuse to use anything but case-constructs to parse arguments these days - if you find that more readable, sure, go ahead. I also disagree with mixing upper and lower case characters in variable names ($opt_V) and with using single square bracket evaluations when writing BASH ([). [[ is much safer, you can do regular expression matching, etc. A case construct will greatly enhance the way you can parse arguments as they don't even need to be prefixed with - or --, you can just parse whatever you want, for me, personally, an absolute plus.

1
SchreqOP•

You didn't address any of my critique points and are really grasping at straws with yours...

My option parser dynamically sets variables using the option letters as a suffix. That's why you see something like $opt_V in my usage example. Got a problem with variable names mixing upper and lowercase? Simply don't use uppercase options (like -V) in your script!

My script has a /bin/sh shebang and hence can't use bashisms like [[.

[Edit] s/prefix/suffix/

1
fuckwit_•

Please never ever eval anything that the user can manipulate. With enough escaping this thing can eval some very very bad commands by passing them as options.

1
SchreqOP•

Short answer: No, my eval usage is safe.

Long answer: Look at this shell session:

$ user_supplied='harmless;echo PWNED'
$ eval "var=$user_supplied"
PWNED
$ echo "$var"
harmless

Obviously that's very bad but not exactly what I do. What I do is basically this:

$ eval "var=\$user_supplied"
$ echo "$var"
harmless;echo PWNED

That's kinda pointless, because we could simply do var=$user_supplied instead, without the eval. However, the fundamental problem is the variable expansion before eval runs, which I don't do with the actual unsafe input from the user. Here's what I actually do:

OPTARG='harmless;echo PWNED' # User supplied.

for o in a b c; do
    eval "var_$o=\$OPTARG"
done

Only $o gets expanded before eval but because the user does not directly control the content, only which content, it is safe. $o is set by getopts and can only ever be a single letter from its optstring argument, which is not controlled by the user.

1
fuckwit_•

While I'm not to keen on OPTARG i know that this is save.

The thing that I really don't like is relying on another tool to (in this case getopts) to do the right stuff with the user input. We have had enough major vulnerabilities where the tool that's is supposed to be save was indeed not save enough and would allow and process faulty input. If getopts has such a vulnerability you would happily execute the Trojan someone passed as an argument. I know the chance of that happening is very very small, but without eval you could make it even smaller if not zero.

I would have used 'declare' in this case to avoid evaluation of user input. I'm not too sure though if declare works across the different shells that you target.

1
SchreqOP•

Fair point and I would totally agree but getopts is a shell built-in. It has to be, so that it can set variables within the main process. If we can't even trust shell built-ins, why trust the shell as a whole?

Unfortunately declare is not portable. Only other option is export.

2