This file aims to demonstrate modernish by showing side-by-side comparisons of plain POSIX shell script and modernish script.
For documentation, see README.md.
This script sets the timestamps on working directory files within a local Git
repository to the date of the last commit in which they were changed, so you
can use your regular ls -l
to see when the files were last changed, ls -lt
to sort by last-modified, and so on. If you first change to a subdirectory of
the repo, it will only restore the timestamps down from that directory.
Plain POSIX sh version | # | Modernish version |
---|---|---|
#! /bin/sh
git status >/dev/null || exit
if ! git diff-index --quiet HEAD; then
echo 'Working dir not clean' >&2
exit 1
fi
find . -name .git -prune \
-o -exec sh -c '
# Ask Git for latest commit'\''s timestamp,
# formatted for POSIX '\''touch -t'\''.
timestamp=$(git log --format=%cd \
--date=format:%Y%m%d%H%M.%S \
-1 HEAD -- "$1") || exit
[ -n "$timestamp" ] || exit
set -x
touch -t "$timestamp" "$1"
' dummy {} \; |
|
#! /usr/bin/env modernish
#! use safe
#! use sys/cmd/harden
#! use var/loop
harden git
harden -e '>1' -f wd_is_clean \
git diff-index --quiet HEAD
harden -pt touch
git status >/dev/null
wd_is_clean || exit 1 'Working dir not clean'
total=0
LOOP find repofile in . -name .git -prune \
-or -iterate; DO
# Ask Git for latest commit's timestamp,
# formatted for POSIX 'touch -t'.
timestamp=$(git log --format=%cd \
--date=format:%Y%m%d%H%M.%S \
-1 HEAD -- $repofile)
str empty $timestamp && continue
# 'touch' is traced by 'harden -t'.
touch -t $timestamp $repofile
let "total+=1"
DONE
exit 0 "$total timestamps restored." |
This simple script demonstrates two main aspects of modernish:
command hardening, and the find
loop. It also shows the safe
mode, how to write a portable-form script, and how to use modules.
- Line 1:
The hashbang path of the modernish version indicates a
portable-form modernish script.
The script is guaranteed to be executed by
a shell that passed modernish's fatal bug tests.
By contrast, the
#!/bin/sh
hashbang path is not guaranteed to lead to any shell in particular; it could be an original Bourne shell without any modern POSIX features (like on Solaris 10), or pdksh which breaks the safe mode, or nothing at all. The/usr/bin/env
utility path is a de-facto standard: not formally standardised, but very portable in practice. - Line 2: The safe mode disables default splitting and globbing, none of which we need in this script. This makes unquoted variable expansions and command substitutions safe to use (lines 18, 19, 22).
- Lines 3, 5-7:
Command hardening
is optional; this script will work without, as the POSIX sh version does.
However, it is highly recommended for securing and debugging your script. To
demonstrate this, try introducing an argument error to the
git
command in the command substitution on lines 20-22 – for instance, change--format=%cd
to--format=@cd
, a format error in git. If you try this, you will see how a fatal error in a hardenedgit
command will reliably cause the script to terminate at the exact point where the fatal error occurred, producing one error message showing the exact command that failed – even if it was executed in a subshell environment (the command substitution) which is normally not capable of exiting the main script. This makes debugging easy. Introducing the same typo in the POSIX sh version will not cause the script to terminate; instead, it continues, producing an error message for each file found. For a trivial script like this, this difference may not be very important, but for more complex scripts, conventional shell quickly becomes too difficult to debug, and the resulting inconsistent state may be dangerous to your data – whereas modernish with hardened commands remains just as easy to debug, and ensures faulty commands will not cause any damage. I make typos all the time, so this feature has saved me many times. Hardening will similarly terminate the script if the utility itself is not found, is killed by a signal, or somehow cannot be invoked – so it also keeps your script from continuing and potentially causing damage in case of system errors, such as out of memory, a hard disk fault, etc.- Line 6:
This demonstrates a slightly more complex use case for command hardening.
By default,
harden
considers any non-zero exit status to be a fatal error; theharden git
command in line 5 hardensgit
like that. In specific cases where a non-zero exit status is valid, such as when checking if the current repo working directory is clean, it is helpful to harden the command under a separate name. The-e '>1'
exit status expression tellsharden
that any exit status other than 0 or 1 is a fatal error. The-f wd_is_clean
command causes this hardened command to be defined as a shell function namedwd_is_clean
. Extra command arguments togit
given in the hardening definition are simply passed on as is. Thus we get a hardened, securedwd_is_clean
command that will reliably terminate the script on encountering any unexpected condition. - Line 7:
The
-p
option causesharden
to search for thetouch
command in$DEFPATH
, the system default path output bygetconf PATH
that guarantees finding all standard POSIX utilities but nothing else. Using the-p
option is recommended when hardening a standard utility; it increases security as it guarantees you get the system utility and not some other command by the same name. The-t
option enables tracing for hardened commands; it pretty-prints a colourised trace showing the exact command executed, without all the extra noise produced by usingset -x
(set -o xtrace
) in combination with a shell library.
- Line 6:
This demonstrates a slightly more complex use case for command hardening.
By default,
- Lines 4, 15, 23-24:
Robust processing of arbitrary file names (including whitespace, newlines,
etc.) using POSIX
find
is possible; the left-side POSIX script shows how to do it. The only way is to launch an external command with-exec
. If you'd like that external command to do anything slightly complicated, the typical POSIX idiom involves-exec
ing a childsh
that uses the-c
option to run its own script from scratch, once per file name. That whole script needs to be one argument, properly quoted, followed by a dummy argument to set$0
(the script name) in the child shell, followed by{}
which is replaced by each file name and becomes the first positional parameter$1
in the script, followed by a quoted semicolon that signals the end of the-exec
primary tofind
. Disadvantages are evident. It is not possible to stop on error; if the child script exits due to an error,find
will simply continue to the next anyway. A separate script that cannot access any of your main shell's variables or shell functions, or vice versa. And the child script is executed by whateversh
command is found first in your user's$PATH
, which is not necessarily a known entity. To avoid these problems, many shell scripts instead parsefind
output in afor
loop with a command substitution, using field splitting in extremely unsafe ways.
By contrast, modernish can useLOOP find
. This loop type of the generic modernish loop construct integrates thefind
utility into the shell so it can be used in the same way you'd use a regularfor
loop. Arbitrary file names are processed correctly by default and stored in a variable, as withfor
. Further processing is done in the loop body which is part of your main script, so it will use your shell settings (e.g. safe mode), functions, variables, and whatnot. To demonstrate this, we add a little feature to the modernish version: count the total number of files processed, using a variable that survives the loop like any other. - Line 9: Since
git
is hardened, an|| exit
would be superfluous. - Lines 10, 25:
The enhanced exit
command allows specifying an error or informative message.
This removes the need for a separate
echo
, which makes a more concise coding style possible when checking for error conditions. - Line 19: The modernish replacement for
test
/[
is safe for leaving variables unquoted, unlike[ -n ... ]
ortest -n ...
. The thing here is that an unquoted variable is removed if empty, so with the test command, you effectively end up with[ -n ]
ortest -n
. Both of these yield a false positive, as this is taken as an alternative syntax for testing if a string is empty, with-n
itself (which is non-empty) being the string tested. By contrast, the operators to thestr
command deal correctly with empty removal and return the expected exit status. Like all other modernish functions, the replacements fortest
/[
are also hardened with paranoid argument and bounds checking, reliably terminating the script if a fatal mistake is encountered (such as excess arguments due to unexpected field splitting or globbing). - Line 23: With modernish, the
let
arithmetic command is made available on all supported POSIX shells. The++
and--
unary operators are not supported by all shells, so to increase a variable's value, we use+=1
instead.
More side-by-side example scripts with discussion to follow.