How to Validating User crontab Entries using Script

Posted on 8:19 PM by Bharathvn

One of the most helpful facilities in Unix is cron, with its ability to schedule jobs at arbitrary times in the future, recurring every minute, every few hours, monthly, or annually. Every good system administrator has a Swiss army knife of scripts running from the crontab file.

However, the format for entering cron specifications is a bit tricky, and the cron fields have numeric values, ranges, sets, and even mnemonic names for days of the week or months. What's worse is that the crontab program generates insufficient error messages when scanning in a cron file that might be incorrectly structured.

For example, specify a day of the week with a typo, and crontab reports

"/tmp/crontab.Dj7Tr4vw6R":9: bad day-of-week
crontab: errors in crontab file, can't install

In fact, there's a second error in the sample input file, on line 12, but crontab is going to force us to take the long way around to find it in the script because of its poor error-checking code.

Instead of doing it crontab's way, a somewhat lengthy shell script can step through the crontab files, checking the syntax and ensuring that values are within reasonable ranges. One of the reasons that this validation is possible in a shell script is that sets and ranges can be treated as individual values. So to test whether 3–11 or 4,6,9 are acceptable values for a field, simply test 3 and 11 in the former case, and 4, 6, and 9 in the latter.

The Code
#!/bin/sh

# verifycron - Checks a crontab file to ensure that it's
# formatted properly. Expects standard cron notation of
# min hr dom mon dow CMD
# where min is 0-59, hr is 0-23, dom is 1-31, mon is 1-12 (or names)
# and dow is 0-7 (or names). Fields can be ranges (a-e), lists
# separated by commas (a,c,z), or an asterisk. Note that the step
# value notation of Vixie cron (e.g., 2-6/2) is not supported by this script.


validNum()
{
# Return 0 if valid, 1 if not. Specify number and maxvalue as args
num=$1 max=$2

if [ "$num" = "X" ] ; then
return 0
elif [ ! -s $(echo $num | sed 's/[[:digit:]]//g') ] ; then
return 1
elif [ $num -lt 0 -o $num -gt $max ] ; then
return 1
else
return 0
fi
}

validDay()
{
# Return 0 if a valid dayname, 1 otherwise

case $(echo $1 | tr '[:upper:]' '[:lower:]') in
sun*|mon*|tue*|wed*|thu*|fri*|sat*) return 0 ;;
X) return 0 ;; # special case - it's an "*"
*) return 1
esac
}

validMon()
{
# Return 0 if a valid month name, 1 otherwise

case $(echo $1 | tr '[:upper:]' '[:lower:]') in
jan*|feb*|mar*|apr*|may|jun*|jul*|aug*) return 0 ;;
sep*|oct*|nov*|dec*) return 0 ;;
X) return 0 ;; # special case, it's an "*"
*) return 1 ;;
esac
}

fixvars()
{
# Translate all '*' into 'X' to bypass shell expansion hassles
# Save original input as "sourceline" for error messages

sourceline="$min $hour $dom $mon $dow $command"
min=$(echo "$min" | tr '*' 'X')
hour=$(echo "$hour" | tr '*' 'X')
dom=$(echo "$dom" | tr '*' 'X')
mon=$(echo "$mon" | tr '*' 'X')
dow=$(echo "$dow" | tr '*' 'X')
}

if [ $# -ne 1 ] || [ ! -r $1 ] ; then
echo "Usage: $0 usercrontabfile" >&2; exit 1
fi

lines=0 entries=0 totalerrors=0

while read min hour dom mon dow command
do
lines="$(($lines + 1))"
errors=0

if [ -z "$min" -o "${min%${min#?}}" = "#" ] ; then
continue # nothing to check
elif [ ! -z $(echo ${min%${min#?}} | sed 's/[[:digit:]]//') ] ; then
continue # first char not digit: skip!
fi


entries="$(($entries + 1))"

fixvars

#### Broken into fields, all '*' replaced with 'X'
# Minute check

for minslice in $(echo "$min" | sed 's/[,-]/ /g') ; do
if ! validNum $minslice 60 ; then
echo "Line ${lines}: Invalid minute value \"$minslice\""
errors=1
fi
done

# Hour check
for hrslice in $(echo "$hour" | sed 's/[,-]/ /g') ; do
if ! validNum $hrslice 24 ; then
echo "Line ${lines}: Invalid hour value \"$hrslice\""
errors=1
fi
done

# Day of month check

for domslice in $(echo $dom | sed 's/[,-]/ /g') ; do
if ! validNum $domslice 31 ; then
echo "Line ${lines}: Invalid day of month value \"$domslice\""
errors=1
fi
done

# Month check

for monslice in $(echo "$mon" | sed 's/[,-]/ /g') ; do
if ! validNum $monslice 12 ; then
if ! validMon "$monslice" ; then
echo "Line ${lines}: Invalid month value \"$monslice\""
errors=1
fi
fi
done

# Day of week check

for dowslice in $(echo "$dow" | sed 's/[,-]/ /g') ; do
if ! validNum $dowslice 31 ; then
if ! validDay $dowslice ; then
echo "Line ${lines}: Invalid day of week value \"$dowslice\""
errors=1
fi
fi
done

if [ $errors -gt 0 ] ; then
echo ">>>> ${lines}: $sourceline"
echo ""
totalerrors="$(( $totalerrors + 1 ))"
fi
done < $1

echo "Done. Found $totalerrors errors in $entries crontab entries."

exit 0


How It Works
The greatest challenge in getting this script to work is sidestepping problems with the shell wanting to expand the field value *. An asterisk is perfectly acceptable in a cron entry, and indeed is quite common, but give one to a backtick command and it'll expand to the files in the current directory — definitely not a desired result. Rather than puzzle through the combination of single and double quotes necessary to solve this problem, it proves quite a bit simpler to replace each asterisk with an X, which is what the fixvars function accomplishes.

Also worthy of note is the simple solution to processing comma-and dash-separated lists of values. The punctuation is simply replaced with spaces, and each value is tested as if it were a stand-alone numeric value. That's what the $() sequence does in the for loops:

$(echo "$dow" | sed 's/[,-]/ /g')

With this in the code, it's then simple to step through all numeric values, ensuring that each and every one is valid and within the range for that specific crontab field parameter.

Running the Script
This script is easy to run: Just specify the name of a crontab file as its only argument. To work with an existing crontab file, do this:

$ crontab -l > my.crontab
$ verifycron my.crontab
$ rm my.crontab

The Results
Using a sample crontab file that has two errors and lots of comments, the script produced these results:

$ verifycron sample.crontab
Line 10: Invalid day of week value "Mou"
>>>> 10: 06 22 * * Mou /home/ACeSystem/bin/del_old_ACinventories.pl

Line 12: Invalid minute value "99"
>>>> 12: 99 22 * * 1-3,6 /home/ACeSystem/bin/dump_cust_part_no.pl

Done. Found 2 errors in 17 crontab entries.

The sample crontab file with the two errors, along with all the shell scripts explored in this book, are available at the official Wicked Cool Shell Scripts website, at http://www.intuitive.com/wicked/

Hacking the Script
Two enhancements would be potentially worth adding to this script. Validating the compatibility of month and day combinations would ensure that users don't schedule a cron job to run on, for example, 31 February, which will never happen. It could also be useful to check that the command being invoked can be found, but that would entail parsing and processing a PATH variable (i.e., a list of directories within which to look for commands specified in the script), which can be set explicitly within a crontab file. That could be quite tricky. . . .