BashWget, une (pale) copie de Wget

Qu'est-ce que BashWget ?

BashWget est une imitation très limitée de l'utilitaire GNU Wget.

Très limitée car le but n'était pas de réécrire entièrement Wget mais de montrer quelques-unes des fonctionnalités avancées de Bash.

Le script BashWget est entièrement écrit en Bash et n'utilise quasiment que des fonctionnalités internes de Bash (à l'exception de la commande cat) :

Un des avantages de n’utiliser que des fonctions internes de Bash est d’accélérer la vitesse du script car on limite fortement le lancement de sous-processus.

Le code source se trouve en bas de cette page.

Explication de code

Définition de CRLF

La variable $CRLF contient le code le deux octets nécessaires à la génération d’un retour à la ligne compatible avec les en-têtes HTTP. CR pour Carriage Return et LF pour Line Feed.

Pour créer cette variable on utilise les chaînes type C de Bash. En écrivant CRLF=$'\r\n', au lieu de CRLF="\r\n", Bash va remplacer \r par le caractère de contrôle 0x0d (Carriage Return) et \n par le caractère de contrôle 0x0a (Line Feed). Dans le deuxième cas, c’est la chaîne \r\n (4 caractères) qui aurait été affectée à CRLF.

Variables locales avec local

Bash permet de définir des variables locales dans les fonctions. C’est très pratiques pour éviter les effets de bords car, par défaut, les variables sont globales.

Cela permet également d’indiquer comment sont utiliser les paramètres de la fonction (les fonctions sous Bash n’ont pas de paramètres nommés). Par exemple, pour la fonction http_request, la définition des variables locales se fait en même temps que leur initialisation :

local method="$1" host="$2" path="$3"

On voit donc au premier coup d’œil que la fonction http_request prend trois paramètres : method, host et path.

Connexion TCP

Bash est capable de se connecter en TCP à un serveur grâce à des chemins spéciaux. Ceux-ci n’ont aucune existence du point de vue système, seul Bash effectue le lien.

Pour se connecter à un serveur Web, le protocole TCP est utilisé en utilisant un chemin spécial de la forme /dev/tcp/hôte/port. Par exemple, pour se connecter à http://www.example.com, on utilisera le chemin /dev/tcp/www.example.com/80.

Pour établir connexion, on fait appel à la fonction exec de Bash :

exec 3<> "/dev/tcp/www.example.com/80"

Cette commande demande à Bash de connecter le descripteur de fichiers 3 en lecture/écriture (<>) sur la socket connectée à www.example.com sur le port 80.

La lecture du chapître suivant est fortement recommandée…

Manipulation des descripteurs de fichiers avec exec

La fonction exec de Bash permet normalement de remplacer le processus courant par un autre (donc sans en lancer de nouveau).

Mais, comme souvent en Bash, elle a d’autres fonctionnalités qui n’ont pas grand chose à voir avec le but initial.

La fonction exec permet de manipuler les descripteurs de fichiers. Bien que nous disposions déjà des redirections et des tubes, il existe au moins un cas où ceux-ci ne peuvent être utilisés : lorsqu’on utilise la fonction exec elle-même !

Dans le précédent chapître était présenté l’appel exec 3<> "/dev/tcp/www.example.com/80". Que se passe-t-il si jamais la connexion au serveur ne peut pas être établie ? La fonction exec envoie alors un message d’erreur sur la sortie standard. Si on veut contrôler l’affichage des messages d’erreur, il n’est malheureusement pas possible d’utiliser une redirection sur la fonction exec. Si on tente un exec 2>/dev/null, Bash comprendre que l’on veut rediriger la sortie d’erreur du processus en cours sur /dev/null.

Pour contourner le problème, on va utiliser exec pour effectuer une sauvegarde de la sortie d’erreur courante dans un descripteur de fichier inutilisé (4>&2) puis la rediriger vers /dev/null (2> /dev/null) :

exec 4>&2 2> /dev/null

On utilise à nouveau exec pour restaurer la sortie d’erreur (2>&4) et fermer le descripteur de fichier utilisé pour la sauvegarde (4>&-) :

exec 2>&4 4>&-

Génération d’une requête minimale mais propre

La fonction http_request sert uniquement à générer une requête minimale mais propre qui répond aux standards.

Elle prend 3 paramètres :

La fonction http_request utilise la fonction printf pour générer l’entête HTTP car elle permet un contrôle fin des séquences de caractères générées notamment pour le retour à la ligne qui diffère du standard Unix.

La fonction http_request se présente ainsi :

function http_request {
  local method="$1" host="$2" path="$3"

  printf '%s %s HTTP/1.0\r\n' "$method" "$path"
  printf 'User-Agent: BashWGet\r\n'
  printf 'Accept: */*\r\n'
  printf 'Host: %s\r\n' "$host"
  printf 'Connection: close\r\n'
  printf '\r\n'
}

Les différents printf vont générer dans l’ordre pour l’appel http_request GET www.example.com / :

Manipulation de chaînes et découpage d’URL

Bash fournit un ensemble de facilités pour manipuler les chaînes de caractères sans devoir recourir aux outils classiques tels que cut, sed, grep etc.

Je parle de facilités car ce ne sont pas des fonctions ou des commandes. Ces manipulations se font via l’expansion des paramètres shell.

Équivalent de strlen

Pour émuler la fonction strlen en Bash, il suffit d’utiliser un # comme suit :

${#variable}

L’expansion va retourner le nombre de caractères. Exemple :

variable='Frédéric'
${#variable} → 8

À noter : cette expansion gère l’UTF-8

Équivalent de substr

La fonction substr des langages évoluées peut être remplacée par l’expansion suivante :

${variable:debut:longueur}

Quelques exemples :

variable='Frédéric'
${variable:0:5} → Frédé
${variable: -4} → éric

À noter :

Extraction par motif

Bash permet de supprimer des caractères d’une chaîne en se basant sur des motifs semblables à ceux utilisés pour désigner des fichiers sur la ligne de commande.

Les quatres formes sont les suivantes :

${variable#motif}
${variable##motif}
${variable%motif}
${variable%%motif}

# et ## permettent de supprimer un motif en début de chaîne tandis que % et %% permettent de supprimer un motif en fin de chaîne. La différence entre les simples et les doubles tient dans le fait que les simples suppriment le plus petit morceau de chaîne possible alors que les doubles suppriment le plus grand morceau de chaîne possible.

Quelques exemples :

variable='www.example.com/chemin/vers/ressource'
${variable#*/} → chemin/vers/ressource
${variable##*/} → ressource
${variable%/*} → www.example.com/chemin/vers
${variable%%/*} → www.example.com

On s’aperçoit immédiatement qu’il est ainsi très simple de couper une chaîne en deux par rapport à un séparateur :

variable='www.example.com/chemin/vers/ressource'
${variable%%/*} → www.example.com
${variable#*/} → chemin/vers/ressource

Par exemple, si on veut découper une URL en Bash

url='http://www.example.com:8080/chemin/vers/ressource'
hostportpath="${url#http://}"  → www.example.com:8080/chemin/vers/ressource
path="${hostportpath#*/}"      → chemin/vers/ressource
hostport="${hostportpath%%/*}" → www.example.com:8080
host="${hostport%:*}"          → www.example.com
port="${hostport#*:}"          → 8080

Note : cette technique ne peut s’appliquer que pour des chaînes construites simples, dans le cas des URL elle n’est pas adaptée si on veut gérer toutes les variantes possibles.

Code source

Télécharger le fichier source de bashwget

#!/usr/bin/env bash

# CRLF is the fields separator in the HTTP headers
CRLF=$'\r\n'

function http_request {
  local method="$1" host="$2" path="$3"

  printf '%s %s HTTP/1.0\r\n' "$method" "$path"
  printf 'User-Agent: BashWGet\r\n'
  printf 'Accept: */*\r\n'
  printf 'Host: %s\r\n' "$host"
  printf 'Connection: close\r\n'
  printf '\r\n'
}

function http_get {
  local host="$1" port="$2" path="$3"

  # Save standard error on file descriptor 4 and redirects it to /dev/null
  # We do this because we cannot directly redirect standard error of the next
  # exec command
  exec 4>&2 2> /dev/null

  # Open a connection to the host with a specific port
  exec 3<> "/dev/tcp/$host/$port"
  rc=$?

  # Restore standard error from file descriptor 4
  exec 2>&4 4>&- 

  [ $rc -ne 0 ] && return $rc

  # Send the request
  http_request GET "$host" "$path" >&3

  # Read the answer
  cat <&3
}

function parse_url {
  local url="$1" hostportpath path hostport host port

  # Parse URL
  hostportpath="${url#http://}"  # Remove protocol, get host, port and path
  path="${hostportpath#*/}"      # Remove host from hostportpath to get path
  hostport="${hostportpath%%/*}" # Remove path from hostportpath to get hostport
  host="${hostport%:*}"          # Remove port from hostport to get host
  port="${hostport#*:}"          # Remove host from hostport to get port

  # Due to nature of Bash string manipulation, if $path is the same as
  # $hostportpath if means the user hasn't specified a path
  test "$path" == "$hostportpath" && path=""

  # Due to nature of Bash string manipulation, if $host is the same as $hostport
  # if means the user hasn't specified a port. Defaults to 80
  test "$host" == "$hostport" && port=80

  # Add the preceding slash to the path, because it was removed when parsed
  path="/$path"

  printf '%s %s %s' "$host" "$port" "$path"  
}

function apply_location {
  local host="$1" port="$2" path="$3" location="$4"

  # Determine if the location is absolute or relative
  if [ "${location:0:7}" == "http://" ]
  then
    # Location is absolute
    set $(parse_url "$location")
    host="$1" port="$2" path="$3"
  else
    # Location is relative, there are 3 cases to handle
    if [ "${location:0:1}" == '/' ]
    then
      # Location is absolute relatively to the host
      path="$location"
    else
      if [ "${path: -1}" == '/' ]
      then
        # The current path is a directory, location is relative to it
        path="$path$location"
      else
        # The current path is a document, location is relative to its
        # directory
        path="$(dirname "$path")/$location"
      fi
    fi
  fi

  printf '%s %s %s' "$host" "$port" "$path"
}

function usage {
cat <<EOF
usage: $0 [-h] [-e] [-b] <url>

BashWget is a free utility for non-interactive download of files from the Web.
It supports HTTP only. It’s a very limited copy of the GNU Wget utility.
Options are cumulative.

OPTIONS:
    -h   show this message
    -e   output HTTP header response
    -b   output HTTP body response
    -c   output status code
    -q   quiet mode
    url  URL to retrieve, only HTTP is supported with optional port and path
         url must be the last parameter
EOF
}

# Parse arguments
showheader="" showbody="" showstatus="" quietmode=""
while getopts "hebcq" OPTION
do
  case $OPTION in
    h) usage ; exit 1 ;;
    e) showheader="on" ;;
    b) showbody="on" ;;
    c) showstatus="on" ;;
    q) quietmode="on" ;;
    ?) usage ; exit ;;
  esac
done

# Delete options we have already processed
shift $((OPTIND-1))

# Parse URL
set $(parse_url "$1")
host="$1" port="$2" path="$3"

while [ "$path" ]
do
  [ "$quietmode" ] || printf 'Trying [%s] [%s] [%s]\n' "$host" "$port" "$path" >&2

  # Run the HTTP request
  answer=$(http_get "$host" "$port" "$path")

  # Split answer into header and body
  header="${answer%%$CRLF$CRLF*}"
  body="${answer#*$CRLF$CRLF}"

  # Due to nature of Bash string manipulation, if $header is the same as
  # $body
  test "$header" == "$answer" && body=""

  # Analyze the status
  status=( ${header%%$CRLF*} ) # Split the first line of the header
  status_code="${status[1]}"   # Status code is the second parameter

  # Look for a Location line in the header
  location="${header#*${CRLF}Location: }"

  if [ "$location" != "$header" ]
  then
    # A Location line has been found, get the destination
    location="${location%%$CRLF*}"

    # Apply location to the current URL
    set $(apply_location "$host" "$port" "$path" "$location")
    host="$1" port="$2" path="$3"
  else
    # There is nothing left to do, end the while loop
    location=""
    path=""
  fi
done

test "$showstatus" && printf '%s\n' "$status_code"
test "$showheader" && printf '%s' "$header"
test "$showbody"   && printf '%s' "$body"