Contrôler les données, c'est à la fois sécuriser le programme (voir les excellents articles sur Linux Focus et dans les précédents articles de Linux Magazine sur éviter les failles de sécurité dès le développent d'une applicatoin), et documenter ces données.
Les données en entrée et en sortie d'un programme doivent avoir un format précisé (pas forcément précis, surtout si le programme implémente une intelligente artificielle efficace). Ce format doit être documenté sous peine que votre projet soit à jamais incompatible avec vos concurrents, ce qui entrainerait une mort probable du projet. Le plus simple est d'utiliser un format déjà connu donc déjà documenté, ou un format trivial. Ainsi, le format de text ASCII est trivial: ce sont des caractères ASCII et la fin de ligne est un retour chariot, parfois accompagné d'un line feed suivant le système. Le format d'envoi d'un mail à un serveur SMTP est défini dans les RFC (RFC 821 pour le protocole, et d'autres pour des extensions ou services annexes). Si vous définissez un format propre à votre application, documentez-le: écrivez un fichier texte, voire sgml ou html avec les spécifications de votre format de données.
Pour les données en entrée, vérifiez toujours leur intégrité. Si les données viennent d'une source sure (un autre programme qui vient de formatter ces données pour les mettre en entrée de votre programme), des vérifications basiques suffiront. Vérifiez qu'il y a bien des données, vérifiez l'en-tête, la longueur et éventuellement d'autres choses simples et rapides. Si la source n'est pas sure, ce qui est le cas des données venant de l'internet ou d'une interface utilisateur, effectuez une vérification plus approfondie. Testez qu'il n'y a pas de lettres dans le nombre que vous attendez. Vérifiez que le fichier que votre parseur xml attend n'est pas une image au format png! Vérifiez la syntaxe xml avant d'en extraire les données.
Les données en sortie sont généralement au format attendu étant donné que c'est votre programme qui les a traitées. Du coup, un bon test peut être un programme externe qui va tester l'intégrité des données issues de votre programme. Ce programme testeur validera que votre programme ne fait pas n'importe quoi. D'ailleurs, pour les programmes qui lisent un fichier, le modifient puis sauvent les modifications (c'est le cas des logiciels de traitement de texte, d'image...), il suffit de sauver un fichier avec votre logiciel, puis de charger ce même fichier que vous venez de sauver. Sauvez-le à nouveau mais avec un nom différent. Puis l'utilitaire cmp (man cmp) vérifiera que vos deux fichiers sont similaires. diff est à préférer à cmp si les fichiers sont des fichiers texte.
Une remarque tout de même: deux fichiers intègres de même contenu peuvent avoir une organisation différente. Dans ce cas, ne croyez pas avoir introduit un bogue. Simplement, sachez que le test précédent n'est pas possible tel quel. Un tel programme pourrait être un programme qui lit un nombre et le sauve dans son fichier de nombres. Si aucun nombre n'est lu, le fichier est quand même sauvé sans avoir ajouté de nombre. Si le programme sauve les nombres dans un ordre déterminé, alors le test précédent est possible. Mais si le programme inverse volontairement la liste des nombres, voire classe les nombres dans un ordre aléatoire, alors le fichier de sortie contiendra les mêmes nombres, mais le contenu du fichier d'entrée et celui de sortie seront différents malgré le fait que les données n'ont pas changé. Un premier programme de test de validité devra lire le fichier et tester que l'on a des nombres et que des nombres. Un second programme de test de validité lira le fichier et sortira un fichier avec les nombres classés. Ainsi, on pourra tester avec ce second programme de test que les données avant utilisation du programme sont les mêmes que celles après sauvegarde.
Un programme de validation pourra faire partie des outils de la documentation externe de l'application en ce sens que toute personne modifiant l'application pourra tester la validité des modifications. Et dans un document externe, on aura la liste des versions de développement de l'application avec la liste des tests positifs et des tests négatifs. Une version stable doit bien sur avoir l'ensemble des tests positifs.
Si la réalisation d'un programme de validation est compliquée alors que la validation peut se faire en quelques minutes manuellement, il faut écrire un document avec les vérifications à effectuer. Souvent, le coordinateur d'un projet a la liste de ces vérifications en tête, et il les fait manuellement avant se publier une nouvelle version de son projet. Ecrire la liste de ces vérifications permet aux collaborateurs d'effectuer ces vérifications aussi et de trouver d'éventuels bogues avant le coordinateur, ce qui lui simplifie la tâche. De plus, si certains tests sont moins simples à mettre en oeuvre que d'autres, il sera de bon gout de documenter ces jeus de tests et la manière de les utiliser. Eh oui, documenter son projet ne s'arrête pas à mettre des commentaires!
Les données de fonctionnement sont sous deux formes: les données affichées sur la sortie d'erreur, et les données écrites dans un fichier de log. Les données affichées sur la sortie d'erreur servent pour un programme interactif, tel que gcc qui nous informe des erreurs de syntaxe et autres. Les données écrites dans le fichier log viennent de programmes démons qui tournent en tâche de fond. Ces données sont très importantes et la manière de les générer doit être facile car cela ne doit pas être un effort de mettre une ligne dans la log pour le programmeur. De plus, il est fondamental de n'avoir qu'une ligne par erreur et vice versa pour permettre un traitement automatique du fichier log par des agents de surveillance automatiques. Je vais vous proposer quelques fonctions plus bas à utiliser dans un programme quelconque.
#include <stdio.h> #include <stdarg.h> #include <time.h> #include <string.h> typedef enum { FALSE = 0, TRUE = 1 } lu_boolean; #define lu_assert(a) lu_assert_test(__FILE__,__LINE__, #a,a) FILE *lu_error_file_descriptor = NULL; char *error_log_file_name; lu_boolean lu_init_error_log (const char *filename) { lu_error_file_descriptor = fopen (filename, "a"); if (lu_error_file_descriptor == NULL) { printf ("Could not open error log file '%s'\n", filename); return (FALSE); } error_log_file_name = strdup (filename); return (TRUE); } void lu_end_error_log (void) { fclose (lu_error_file_descriptor); } void lu_write_error_line (char *file, int line, char *message, ...) { va_list ap; int d; char c, *s; char date[] = "YYYY/MM/DD HH:MM:SS"; struct tm *timestamp; const time_t c_tm = time (0); if (lu_error_file_descriptor == NULL) { printf ("Error file not open\n"); } timestamp = localtime (&c_tm); snprintf (date, sizeof (date), "%04d/%02d/%02d %02d:%02d:%02d", timestamp->tm_year + 1900, timestamp->tm_mon + 1, timestamp->tm_mday, timestamp->tm_hour, timestamp->tm_min, timestamp->tm_sec); fprintf (lu_error_file_descriptor, "%s (%s:%d) ", date, file, line); va_start (ap, message); while (message[0]) { switch (message[0]) { case '%': message++; switch (message[0]) { case '%': fprintf (lu_error_file_descriptor, "%%"); break; case 's': /* string */ s = va_arg (ap, char *); fprintf (lu_error_file_descriptor, "%s", s); break; case 'd': /* integer */ d = va_arg (ap, int); fprintf (lu_error_file_descriptor, "%d", d); break; case 'c': /* character */ c = va_arg (ap, int); fprintf (lu_error_file_descriptor, "%c", c); break; default: fprintf (lu_error_file_descriptor, "%c", message[0]); } break; default: fprintf (lu_error_file_descriptor, "%c", message[0]); } message++; } va_end (ap); fprintf (lu_error_file_descriptor, "\n"); } void lu_assert_test (char *file, int line, char *assertion, int test) { if (!test) { lu_write_error_line (file, line, "Assertion '%s' FAILED", assertion); } }
Notez que j'ai ainsi défini quatre fonctions et macros utiles:
lu_boolean lu_init_error_log (const char *filename) qui renvoit FALSE si le fichier de log n'a pas pu être ouvert. Il faut utiliser cette fonction au tout début du programme pour initialiser l'ensemble. Techniquement, on ouvre juste le fichier de log.
void lu_write_error_line (char *file, int line, char *message, ...): c'est la fonction principale. Elle s'appelle en donnant le nom du fichier source et la ligne où se trouve l'appel à cette fonction. Les variables __FILE__ et __LINE__ sont là pour cela. Le troisième paramètre est le texte à mettre dans la log, et il est comme printf: on peut utiliser %s, %d et %c. je n'ai pas implémenté les autres comme %f.
lu_assert(a) est une macro qui met un message dans la log si le test a est faux. Cette macro appelle void lu_assert_test (char *file, int line, char *assertion, int test) dans laquelle on peut rajouter un appel à exit si l'on veut quitter à la moindre assertion fausse.
void lu_end_error_log (void) sert à refermer le fichier de log.
Deux remarques avant de passer à l'exemple: j'ai utilisé une fonction à nombre d'arguments variables. Ceci est un exemple court si vous voulez comprendre comment ca marche. Pour de la documentation sur le sujet, tapez man stdarg et vous en saurez plus. Seconde remarque: l'implémentation de ma fonction lu_write_error_line fait que les caractères précédés d'un antislash sont écrit précédés d'un antislash dans la log. Pour être plus clair, si vous mettez un message contenant \n, vous lirez \n et non pas un retour à la ligne. En exercice, vous pouvez implémenter cela: c'est simple car il suffit d'un test sur '\' après le test sur '%'. Pensez tout de même à ne pas implémenter le '\n' car il est necessaire de n'avoir qu'une ligne par message et réciproquement. Mais les autres comme '\t' peuvent être codés.
Voici un petit programme à ajouter à la fin des précédentes lignes de code (mieux, compilez séparément et utilisez l'éditeur de lien; encore mieux: faites une librairie et liez à cette librairie!):
int main () { lu_init_error_log ("aaa.log"); lu_write_error_line (__FILE__, __LINE__, "message %d", 1); lu_write_error_line (__FILE__, __LINE__, "message %s", "'a'"); lu_assert(3>2); lu_assert(3<2); lu_end_error_log (); return (1); }
Ce programme utilise le fichier aaa.log pour écrire sa log. Un premier message est écrit: "message 1". Un second est "message 'a'". Un troisieme indique que l'assertion comme quoi "3<2" n'est pas vrai!
Une petite remarque sur la fonction d'assertion. Cette fonction permet de se faciliter la tâche quand on développe un gros programme. En effet, pour ne pas s'attacher à des détails, pour ne pas perdre de temps sur des cas spécifiques qui risquent de n'arriver qu'exceptionnellement en cas de disfonctionnement, il peut être pratique d'utiliser la fonction d'assertion. En effet, si elle ne stabilise pas le code, elle permet de garder une trace des cas spécifiques, aussi bien au niveau du code que dans l'exécution du programme. Au niveau du code, une recherche permet de trouver les appels à cette fonction pour savoir où le code n'est pas solide. C'est bien plus facile que de chercher dans le vague. Un appel à cette fonction indique un point faible. Au niveau de l'exécution, si une assertion est invalide un peu trop souvent, c'est que le cas n'est pas aussi exceptionnel qu'on l'a pensé initialement, et il faudra coder quelque chose de plus solide. Mais au moins on saura où se trouve le problème.
La fonction d'assertion permet aussi de contourner temporairement les nombreux retour de fonctions dont on ne sait que faire lorsqu'ils indiquent une erreur. Un bon exemple est l'instruction malloc. On affecte son résultat à une variable et on ne teste jamais si l'allocation mémoire s'est bien déroulée. Effectivement, on se dit que si elle s'est ma déroulée, ce n'est pas la suite qui se passera mieux, mais il faut coder quelques lignes pour quitter proprement le programme. Et la paresse légendaire du programmeur l'emporte quasiment toujours: plutôt que de programmer ces lignes, on préfère ne rien voir. Alors dans ce cas, forcez-vous au moins à indiquer le point faible:
chaine = (char*)malloc(sizeof(char)*len); lu_assert(chaine != NULL);
Cela est très simple à écrire, cela indique explicitement qu'il y a un point faible, et si le programme plante, on saura avec le fichier de log si l'assertion était vraie ou si le problème vient de là.
Ces fonctions sont implémentées dans la librairie glib, que je conseille à ceux qui ne l'utilisent pas encore: cette librairie facilite grandement l'utilisation des structures de données complexes, de la simple liste chaînée jusqu'à la table de hachage en passant par l'optimisation de l'allocation mémoire. Dans notre cas, la fonction d'assertion s'appelle g_assert et elle n'est pas seule. De plus on y trouve des fonctions de gestion de chaînes de caractères très sympathiques comme g_strndup ou la très puissante g_strdup_printf.
© 2001 Yves Mettier