fun with POSIX

3 minute read

Write a shell script seems to be easy and fun when you know the basics. You can check each separate command in a console, combine results of them. Even, if you are not familiar with scripting, you can create powerful code. A little more knowledge is required to write portable code. There is plenty of shells on Linux distributions, but only a few of them are popular: bash, dash, ash, zsh. All of them have a common root called POSIX standard(s). How you can imagine, a common part of many things is small. It means that write portable code is much harder, you have to deal with limited commands. When you try to use non-POSIX notation bad things may happen. Most common mistakes even have a name – Bashism.


Since many years I deal with ash used as a default shell on BusyBox. Ash is close to POSIX due to its complexity. I have learnt many tricks, notations valid on each shell. To reduce the number of mistakes I also use shell linter ShellCheck. But, as always, there are things you don’t know, or I should say: you know nothing.


I’m during porting a company solution from one Jenkins server used also as executor to a distributed solution with many agents. Because the biggest part of old configuration, scripts are still valid I reuse them in the new environment. On Jenkins, one of the most used and more powerful commands is sh. It executes – surprise – a shell script. When a shebang is not present in your script the default shell is used. Which one is it on your computer? You can check it:

dirdival@discworld:~$ ls -lhat /bin/sh
lrwxrwxrwx 1 root root 4 sty 24  2017 /bin/sh -> dash

In my case it’s dash. Let’s back to the story. I migrated a new pipeline to the new solution and started. It seemed to work, but a job failed. It looked like a wrong set up. After small investigation I reduced the problem to one line:

sh ". set_up_environment.sh /a/some/data && call_program"

We have here two common shell tricks. Let’s start with the second one. When we have in line:

$ somthing1 && something2

it means that something2 will be executed only when the first command somthing1 passed without any problems (exit code 0). The first trick refers to the dot command. And here is a small summarise how it works:

dirdival@discworld:~$ LC_ALL=C . --help
.: . filename [arguments]
    Execute commands from a file in the current shell.
    
    Read and execute commands from FILENAME in the current shell.  The
    entries in $PATH are used to find the directory containing FILENAME.
    If any ARGUMENTS are supplied, they become the positional parameters
    when FILENAME is executed.
    ...

I mostly use the dot command to manipulate an environment, especially when a script contains exports. It’s useful in tests, you can easily create a bunch of different set-ups. As a bonus, I use arguments to have more flexible scripts.

A simple example:

dirdival@discworld:/tmp/test$ cat a.sh
#!/bin/sh

PREFIX=$1
export DATAPATH=${PREFIX}/usr/local/share/foo

with call:

dirdival@discworld:/tmp/test$ echo $DATAPATH

dirdival@discworld:/tmp/test$ . ./a.sh /a/some/data
dirdival@discworld:/tmp/test$ echo $?
0
dirdival@discworld:/tmp/test$ echo $DATAPATH
/a/some/data/usr/local/share/foo

But what surprised me was the result of command on Jenkins. It complained about an invalid path. I checked script with ShellCheck

dirdival@discworld:/tmp/test$ docker run --rm -ti -v $(pwd):/mnt koalaman/shellcheck a.sh

no mistakes. I logged in into Jenkins agent machine and checked sh. It shows on dash. In desperation I added set -x to see each executed line and called sh – just for sure.

dirdival@discworld:/tmp/test$ sh
$ set -x
$ echo $DATAPATH
+ echo

$ . ./a.sh /a/some/data
+ . ./a.sh /a/some/data
+ PREFIX=
+ export DATAPATH=/usr/local/share/foo
$ echo $?
+ echo 0
0
$ echo $DATAPATH
+ echo /usr/local/share/foo
/usr/local/share/foo
$ 

Whaaat? Why PREFIX is not set? It’s the first argument passed to the script. I called again:

$ . --help
+ . --help
sh: 6: .: --help: not found

I was amazed. I took the time until I realised my stupid mistake. I still executed the script from bash. I started working on dash after called sh.

Part of dash man:

Builtins
  This section lists the builtin commands which are builtin because they need to perform some operation that can't be performed by a separate process.
  ...

  . file
         The commands in the specified file are read and executed by the shell.

There is no list of arguments here!


Lessons I’ve learnt:

  • always check currently used shell from process ID instead of SHELL value
dirdival@discworld:~$ echo $SHELL
/bin/bash
dirdival@discworld:~$ cat /proc/$$/cmdline; echo
bash
dirdival@discworld:~$ sh
$ echo $SHELL
/bin/bash
$ cat /proc/$$/cmdline; echo
sh
  • don’t pass arguments to dot command

Updated: