Use Long Options in Scripts
matklad.github.io
matklad.github.io
But at the very least the shell is unnecessary here.
run("program --option '{user_input}' > file")
to save some input to a file, and the user's input is: '; bad_command #
then when run() sends that string to the shell, the shell will run: program --option '';
bad_command #' > file
Most languages have something like a safe_exec() that separates the shape of the command from the values of the options, executing "program" with the options and the user_input in the arguments array as data. Skipping the shell step, which would just be building an exec call anyway, removes the opportunity for users to confuse it into doing something else.The list-based API alternative they recommend might look like this:
safe_exec(["program", "--option", user_input], stdout="file")
and it would always exec "program" with argv[1] == "--option" && argv[2] == user_input. If the user_input happens to be: '; bad_command #
...well, then, the user can enjoy the contents of their file. safe_exec(["rm", user_input])
This isn't safe either! Despite clearly saying "safe_exec"!Shelling out is not the only option. People are just saying not to use that option. Better ones won't save you if you purposely do something stupid. They will save you if the user wants to trick you into doing something else.
(sh "cat " file " >" output)
With file being bound to "foo'bar" and output to "x", it is automatically translated into cat 'foo'\''bar' >'x'
This gives you the flexibility to use shell (sometimes it just is the most concise way) while being safe against injection.I believe for example in rust you should be able to do the same.
But for a simple example like this where it's inserting a date which has known properties, it seems fine, and is much more readable.
The API used handles string interpolation correctly: the string literal is parsed at compile time, and the interpolated arguments are never concatenated or escaped, and end up directly as an element of arv array passed to a child. See
https://github.com/tigerbeetle/tigerbeetle/blob/7053ecd2137a...
comptime assert(std.mem.indexOfScalar(u8, cmd, '\'') == null); // Quoting isn't supported yet.
comptime assert(std.mem.indexOfScalar(u8, cmd, '"') == null);
But you can do correct interpolation with simple shell variables, rather than generating shell code strings: $ today='foo; bar' sh -c 'argv git switch --create "release-$today" origin/main'
['git', 'switch', '--create', 'release-foo; bar', 'origin/main']
So that is a test that we can use a plain shell string, without any shell injection bug. (argv is my command to test quoting: python -c 'import sys; print(sys.argv)' "$@" )Note that there's no escaping function needed, because we're not generating any shell code. We're generating an argv array for `/bin/sh` instead.
---
So by invoking with an env var, you can easily create a correct API that uses plain shell
git switch --create "release-$today"
rather than git switch --create release-{today} # what language is this? It's not obvious
If you don't want to use the env var, you can also use git switch --create "release-$1"
And invoke with ['sh', '-c', shell_string, 'unused-arg0', today_string]
With this approach, you don't need 1. any kind of shell escaping
2. any analyzing of pseudo-shell strings, which can't contain quotes
Because you are not generating any shell code. The shell code is constant.If you’re averse to this:
q(“select x where y = ‘“ + v + “‘“)
And instead do this: q(“select x where y = %s”, v)
Then you should be averse to this: x(“foo --option ‘“ + v + “‘“)
And instead do this: x(‘foo --option “$1”’, v)
This is particularly useful when it’s expedient to have one thing piping into another. Like it or not the sh DSL for pipes is excellent compared to doing things natively with execve() and pipe(), just as doing group by and count is far more concise in SQL than doing so natively.Most SQL libraries give you something like q. Writing your own x is as simple as calling sh correctly. In Python, for example:
def x(script, *args):
run([“sh”, “-c”, script, “--“, *args]) SyntaxError: invalid character '“' (U+201C)Your python example at the bottom is correct, in that each separate element is more correct in that it allows each arg to be passed as an element, so there's no option to break out through quoting characters. SQL binds are like that in most ljbraries, even if they don't look like it. The parser knows a single item below there so if it passes it along as such. You cannot escape it in the same way.
sh is smarter than just doing string interpolation and ”$1” is passed on as a single argument, no matter what:
> run(["sh", "-c", 'echo "$1"', "--", 'a"'])
a”
Whereas if it were simple string interpolation, you’d see this: > run(["sh", "-c", 'echo "a""')
--: 1: Syntax error: Unterminated quoted string
It’s the same special casing that gets "$@" right. # cat pvars.sh
#!/bin/bash
echo "\$1=$1"
echo "\$2=$2"
echo "\$3=$3"
# sh -c './pvars.sh "$1" $2 $3' -- 'a b' 'c d'
$1=a b
$2=c
$3=d
The whole point of passing in an array and using something like exec (or system(), if provided as it handled the fork and wait for you) is that you avoid the overhead of the shell starting up at all and parsing the command line, and it lets you define each param exactly as needed since each param is its own array item. You don't need to worry about splitting on space or the shell splitting params on space, or quoting to group items. If you want the param to be: foo "bar baz" quux
as one singular parameter, you just make that the contents of that array item, since no parsing need be done at all.If you have an array of params and you're jumping through hoops to make sure they're interpreted correctly by the shell you call execute a process, you're likely (depending on language and capabilities) wasting both cycles and over complicating the code when you can just call the program you actually want to execute directly and supply the params. Alternatively, if you have all the params as one long string and you want it to be pased as a shell would, then execute the shell and pass that as a param. e.g.
# perl -E 'system("./pvars.sh","a b","c d");'
$1=a b
$2=c d
$3=
# perl -E 'system("./pvars.sh","a b","c","d");'
$1=a b
$2=c
$3=dThat said, this is more of a corner case. In most scenarios, rather than relying on POSIX utilities, there are often better alternatives, such as using library bindings instead of spawning external processes. For example, instead of invoking grep, using something like libpcre could be a more efficient choice.
For non-POSIX utilities like git, hg, rg, ag, etc., using long options makes perfect sense.
...probably a stupid question, but something I have earnestly been wondering about... when does this actually happen nowadays? What POSIX systems are you targeting that aren't one of the major ones (Linux, Darwin, or one of the major BSDs)?
I was writing a shell script a few months ago that I wanted to be very durable, and I targeted sh instead of bash just because, well, it seemed like the correct hacker spirit thing to do... but I don't actually know what system in the past decade (or more) wouldn't have bash.
I more often get burnt in zsh to bash than that however
I have written a bit more about it in these comments:
But being honest - you can install BASH that way:
# pkg install -y bash
FWIW, Darwin/macOS is especially guilty of gobsmackingly ancient coreutils that don’t support long option variants.
What's a good example of such a utility?
(Running an older version of macOS so can't completely exclude this has been updated in a newer version, but I'd be surprised to learn that was true.)
The gobsmackingly ancient GNU software it does have is bash, because it's the last version under GPL 2. I've used Mac OS X since 10.1, so I remember when the default shell was tcsh and /bin/sh was not bash.
That's (basically) the case again on the last few macOS releases. Today, zsh is my shell of choice, including on Linux.
I think MacOS still has bash, so that it, technically, doesn’t count, but it doesn’t have a bash from the past decade, and uses zsh by default.
There's some ambiguity about "have bash". If "having" bash means that (some version of) bash has been ported to the system, there are indeed very few. If "having" means that bash (supporting all options that you need) is available to the user, that could be a lot more. As others have noted, the BSDs, Android and many embedded Linux systems don't come with bash pre-installed, MacOS pre-installed bash is stuck at version 3.2 (which doesn't have associative arrays), and the user could be in an environment that does not allow them to install whatever they need.
But not every system needs that much, and in a lot of cases, using your language's regexp library will be more robust anf easier to write.
Sadly to this day not all BSD distributions have GNU style long options. And the ones that now do only got them fairly recently. So if you want portability you have to use short options as you weep with a bottle of vodka in hand.
Four years in to using it at work for dev environments across mac (x86 & ARM) and various linuxes and can’t imagine going back. I also always make dev environment definitions for my open source projects, so even if people aren’t using nix, there is at least a record of what tools they will need to install to run scripts, tests, etc.
If you're going for portability the best bet is to just read the manual for each of the separate versions and do whatever works.
I would never write a new program with this option, but I do find it a delightful historical oddity.
Quotes live on a different level of abstraction.
With quotes the program will receive a single argument -n␣o␣p␣e instead of multiple ones -n, o, p, e. At least it works on the machine here:
]$ echo "-n o p e"
-n o p e
]$ /bin/echo "-n o p e"
-n o p eAlso - it helps a lot when a utility accepts a path that may or may not contain hyphens.
$ echo 'hack the planet' > --help
$ cat --help
cat: illegal option -- -
usage: cat [-belnstuv] [file ...]
$ cat -- --help
hack the planet
$ rm -vf --help
rm: illegal option -- -
usage: rm [-f | -i] [-dIPRrvWx] file ...
unlink [--] file
$ rm -vf -- --help
--help
$ cat -- --help
cat: --help: No such file or directoryAll of them except for GNU, AFAICT? (That is, only GNU seems to have long options.) Checking manpages for rm(1) as a simple reference, I can't see long options in any of the 3 major BSDs or illumos, and checking Alpine Linux seems to show busybox also only doing short options (sorry, can't find an online doc for this, though it's easy to check in docker if you don't have a machine running Alpine handy). OpenWRT also uses busybox and has the same (lack of) options.
https://man.freebsd.org/cgi/man.cgi?query=rm&apropos=0&sekti...
But yes, that is nice.
It's exactly the same thing people initially loved about docker or vagrant.
I don’t think any of the other options you specified can manage the same thing.
Neither pacman, apt, nor any other package manger require any sort of virtualization. pacman works fine where-ever you have a C compiler including macos, linux, windows, probably even TempleOS. Whatever you want.
If you want to add something to the user environment system wide, the traditional thing to do is to dump a file into `/etc/profile.d/` which will be sourced during shell startup. If you instead want something local to the project, you just make a script that the developer can source, like a python virtualenvironment.
I'm not saying any of these ideas are bad. I am saying that they are easily solvable and have been solved for the past 20 years. Without Nix.
The corollary must be "write programs that take long options".
grep --ignore-case --files-with-matches -- "hello" *.c
Then invoke it as follows: CMD="grep --ignore-case --files-with-matches -- \"hello\" *.c"
ARG_MAX=$(getconf ARG_MAX)
CMD_LEN=${#CMD}
if (( CMD_LEN > ARG_MAX )); then
echo "Error: Command length ($CMD_LEN) exceeds ARG_MAX ($ARG_MAX)." >&2
exit 1
fi
eval "$CMD" # warning, evaluates filenamesSince using Linux exclusively, I don't think I've ever encountered an issue due to too many arguments/length. And it's the first time I'm actively searching online for ARG_MAX.
I understand that different shells might be different, but with reasonable lengths is there any chance of it being relevant (aside from xargs, where it's generally intended, or better, to pass along each argument individually).
Also if you do things like */*/*, then you can quickly get large command lines. Or even if you do long_name/another_long_name/*.
It is really difficult to deal with, because on top of the arg max limit, globs are not guaranteed to be in order.
The solution is not obvious and hard to get to if you don't know the foot guns in advanced and hard to read once implemented.
$ shelltypes script.sh
# Welcome to shelltypes v 3.23.2
# type ‘help’ if you’re stuck
>>> {1) # import POSIX;;
Importing 73 items.
>>> {2} # append loadpath “/opt/local/shelltypes/base”;;
>>> {3} # import base::YOURPROJECT;;
Importing 15 items.
>>> {4} # check “YOURSCRIPT.sh”
Parsing YOURSCRIPT.sh.
Reticulating splines.
Expanding aliases.
Analyzing free shell environment variables.
Found inconsistencies in PATH.
Warning: Low battery!!!
Warning: found free type for ‘shred’, ignoring.
Warning: use of sudo requires password under /etc/sudoers.
Warning: this utility is fake.
Error: use of cat impossible in the presence of mutt.
Found 15 errors.
Try again. Goodbye.
$
Then you can be pretty sure your script isn’t going to do unnecessary harm, and has some proper guardrails in place.> Warning: this utility is fake.
Well played! I guess I got too excited at the possibility of such a tool existing.
(Personally I typically use bash though, largely so I can `set -eEuo pipefail` next.)
Does shelltypes warn against a failure to check for ARG_MAX?
I have seen some heinously long cmdline strings, but nothing close to that. Usually when invocations have creeped up into the O(1k) character limit at places I've worked, I've implemented a "yaml as cmdline args" option to just pass a config file instead.
Have you seen scenarios where this is actually limiting?
That means you will eval all the filenames, so if you have a file with spaces in it will appear as two files, if there is a `$` in the name it will trigger parameter substitution and so on for the other shell meta-characters.
Tell that to Google and Mozilla. /s
And less prone to typos
Something opt-in like dependabot though could be useful.
If not, it isn't.
somecmd \
--option1 \
--option2 $FOO \
--option3 $BARNot sure in which language you use "try shell.exec", but I am not even sure that it is the right way.
It's called learning, apparently something that is now being eschewed in favour of quick superficiality (and making developers more replaceable --- with AI or unskilled offshore labour.) No wonder "modern" software is almost always total shit.
"But I don't have the time to learn," you complain, but ask yourself this instead: Why do you never have the time to do it right, but always the time to do it twice (or however many times is necessary to get a barely-working product)?
No one is complaining about not having time to learn: you’re arguing against a strawman. The point is not to discourage learning, it’s to encourage clarity in a context where it can at times be critically important.
I am of a generation where I see these uses of LLMs as exceptionally lazy or showy. Your ready use of a "chatbot" is not an attribute to broadcast like this.
What's wrong with GNU? they automatically wrong or something?
That's the attitude which is responsible for making software the way it is today: mediocre.
Software isn't as exciting any more, but that excitement isn't gone because newbs don't have to look up what -O stands for anymore. It's because it's wildly more reliable.