Yksinkertainen shelliskriptini toi esiin bugin vanhassa Bash-komentotulkissa

Kyllästyin tänään editoimaan käsin blog.html tiedostoa aina kun halusin lisätä siihen uuden aiheen. Nopeastihan sen sorkkiminen toki vim-tekstieditorilla käy, mutta tämä operaatio olisi pitänyt automatisoida jo heti blogini alkuvaiheessa vuosia sitten.

Luon tavallisesti blogini läppärilläni, jossa pyörii myös Apache www-serveri sivujen esikatseluun tarjoamista varten. Läppärilläni komento bash --version kertoo versioksi GNU bash, version 4.4.19(1)-release (x86_64-redhat-linux-gnu). Mutta kolttonen.fi aktivistisivustoni sijaitsee Internetissä sellaisella www-palvelimella, jossa on vanhassa 32-bittisessä Intel-arkkitehtuurissa pyörivä GNU bash, version 4.3.30(1)-release.

Merkitsin koodini kommentteihin, että tämä shelliskripti vaatii Bash 4.2 version tai uudemman toimiakseen, sillä käytin siinä merkkijonojen käsittelyyn syntaksia, jota vasta Bash 4.2 tukee. Sen uudempia ominaisuuksia en käyttänyt. Mutta nyt tuo merkintäkin on sitten virheellinen.

Kerron bugista kohta lisää. Skriptini on lyhyt ja se on tällainen:

#!/bin/bash
#
# Author: Kalevi Kolttonen 2018-06-13 <kalevi@kolttonen.fi>
#
# REQUIRES: Bash 4.2 or newer
#
# INPUT: blog entry file such as 2018-06-13b.html
# OUTPUT: to stdout

if [ $# -ne 1 ]; then
	printf "%s usage: %s infile.html\n" $(basename $0) $(basename $0) >/dev/stderr
	exit 1
fi

INFILE=$1

if [ ! -f $INFILE ]; then
	printf "%s error: %s not found or is not a regular file\n" $(basename $0) $INFILE >/dev/stderr
	exit 2
fi

# Get the topic, ignore other lines containing h2, remove h2 tags
topic=$(grep '<h2>' $INFILE | head -1 | sed -e 's/<h2>//' -e 's_</h2>__')

# Remove leading directory names
INFILE=${INFILE##*/}

# Remove .html file name suffix
date=${INFILE//.html}

# Remove a trailing alphabetic character
if [[ $date =~ ([[:alpha:]]$) ]]; then
	date=${date::-1}	
fi

OLDIFS=$IFS
IFS=-
read year month day <<< $date
IFS=$OLDIFS

# Remove leading zeroes from day and month
if [[ $day =~ (^0) ]]; then
	day=${day:1}
fi
if [[ $month =~ (^0) ]]; then
	month=${month:1}
fi

printf "<p><a href=\"%s\">%d.%d.%d %s</a></p>\n" $INFILE $day $month $year "$topic"

Läppärilläni skripti toimii oikein:

[kalevi@localhost ~]$ kaiva_topic /home/kalevi/www/blog/2018-06-13b.html
<p><a href="2018-06-13b.html">13.6.2018 Harvinaiset Spirit ja Deep Purple kelanauhat saapuivat</a></p>

Mutta www-serverillä päivämäärä onkin nurinpäin! Katso itse:

kalevi@oiva:~/www/blog$ kaiva_topic 2018-06-13b.html
<p><a href="2018-06-13b.html">2018.6.13 Harvinaiset Spirit ja Deep Purple kelanauhat saapuivat</a></p>

En huomannut tätä bugia skriptini ensimmäisellä versiolla, koska se oli toteutettu eri tavalla. Siinä alkuperäisessä purin date-muuttujan osiinsa seuraavasti:

day=$(echo $date | cut -d- f3)
month=$(echo $date | cut -d- f2)
year=$(echo $date | cut -d- f1)

Kutsuin siis tyypilliseen Unix-tyyliin ulkoisia komentorivityökaluja eli tässä tapauksessa komentoa cut. Bashia käytettäessä komento echo ei minun tietääkseni kutsu ulkoista /bin/echo binääriä, vaan Bash osaa turvautua sen sisäiseen toteutukseen tehokkuussyistä. Tuon asian varmistaa komento:

man builtins

Se kertoo mitkä komennot on toteutettu Bash-komentotulkissa sisäänrakennettuna.

Minulla oli aikaa ja jostain syystä ajattelin, että on vähän tylsää tässä tapauksessa kutsua ulkoista cut-binääriä kolme kertaa kun merkkijonon parsinta onnistuisi helposti ihan komentotulkin sisälläkin ilman tarvetta luoda ulkoisia apuprosesseja. Shellin sisäinen parsinta on tehokkaampaa, mutta käytännössä tällä asialla ei ole merkitystä. Skripti menee läpi silmittömän nopeasti vaikka shelli loisikin cut-prosessin kolmesti. Lisäksi tätä työkalua ei ajeta useasti missään tiukassa luupissa, vaan sitä käytetään kerran tai pari vuorokaudessa. Siitä huolimatta kikkailin huvikseni hieman Bashin IFS eli Input Field Separator muuttujalla ja parsin date-muuttujan sisällön osiinsa yksinkertaisella komentotulkin sisäisellä read-komennolla:

OLDIFS=$IFS
IFS=-
read year month day <<< $date
IFS=$OLDIFS

Tuossa siis määrittelin, että tavuviiva on kenttäerotin tästä eteenpäin, sillä date on muodossa 2018-06-13. Huomaa myös, että ennen IFS-muuttujan asettamista otin vanhan arvon talteen OLDIFS-muuttujaan. Niin pitää aina toimia ennen IFS-muuttujaan kajoamista, sillä parsimisen jälkeen on ensiarvoisen tärkeää, että IFS palautetaan heti takaisin alkuperäiseen arvoonsa. Ellei näin toimita, komentotulkin toiminta menee takuuvarmasti jossain vaiheessa mystisillä tavoilla aivan pieleen!

Bashin sisäisellä read-komennolla voi parsia myös tiedostoista tulevia syötteitä. Silloin käytetään Unixin one-entry-per-line dataformaattiparadigmaa ja syntaksi on vaikka esimerkiksi näin:

while read foo bar baz; do
	echo $foo $bar $baz
done < inputfilename

Näin voidaan välttyä kutsumasta esimerkiksi awk-ohjelmointikieltä shelliskriptistä käsin. Tällöin muistaakseni kuitenkin on se ongelma, että while-luupin alussa julistetut muuttujat näkyvät vain luupin sisällä. Ne eivät ole voimassa luupin jälkeen tulevassa koodissa.


LISÄYS YLLÄ OLEVAAN BLOGIKIRJOITUKSEEN 2018-06-15: Jaahas. Speksasin sitten skriptini väärin. Eihän siinä blog.html tiedostossa koskaan olekaan otettu pois nollia päiväyksistä päivän tai kuukauden kohdalla. Muokkasin siis skriptiä vielä lyhyemmäksi ja nyt nollia ei poisteta.