Tous les projets
2024GitHub

CLCC — C Library Calls Controller

Librairie C/C++ pour intercepter et mocker les appels à la libc pendant les tests unitaires, via dlsym et RTLD_NEXT — sans modifier le code testé.

CC++TestsEpitech

Contexte

En deuxième année à Epitech, dans le cadre du projet myftp, je me suis retrouvé face à un problème classique mais frustrant : comment tester fiablement le comportement de mon code quand une fonction système échoue ? Simuler un connect() qui rate ou un open() qui retourne -1 n'est pas trivial — à l'époque je n'avais pas trouvé de moyen simple de le faire.

C'est après le module d'ASM, en comprenant précisément comment fonctionnent les symboles en C, le linkage dynamique et l'ordre de résolution des symboles, que l'idée m'est venue : utiliser dlsym() avec RTLD_NEXT pour intercepter les appels aux fonctions de la libc et substituer mon propre comportement à la volée.

Comment ça fonctionne

Interception des symboles

Quand un programme C est lié dynamiquement, les appels aux fonctions de la libc ne sont pas résolus à la compilation — ils passent par la PLT (Procedure Linkage Table), une table de sauts que le dynamic linker remplit à l'exécution. CLCC exploite ce mécanisme : en déclarant une fonction avec le même nom qu'une fonction libc (malloc, connect...) dans une bibliothèque liée avant la libc, le linker résoudra le symbole vers la version de CLCC en premier.

Pour appeler ensuite la vraie fonction, CLCC utilise dlsym(RTLD_NEXT, "malloc")RTLD_NEXT demande au résolveur de chercher le symbole à partir de la bibliothèque suivante dans l'ordre de chargement, sautant ainsi par-dessus le wrapper pour atteindre la libc réelle.

Contexte d'état par fonction

Chaque fonction wrappée possède une structure de contrôle statique générée à la compilation :

typedef struct {
    bool     control;       // mock actif ou non
    int      counter;       // appels restants avant activation
    void    *return_value;  // valeur à retourner quand actif
} clcc_malloc_data_ctx_t;

static clcc_malloc_data_ctx_t clcc_malloc_data_ctx = {
    .control = false,
    .counter = 0
};

Le wrapper généré consulte ce contexte à chaque appel :

void *malloc(size_t arg1) {
    if (!ctx.control || ctx.counter > 0) {
        void *(*real_func)(size_t) = dlsym(RTLD_NEXT, "malloc");
        ctx.counter -= 1;
        return real_func(arg1);   // appel réel
    }
    return ctx.return_value;      // valeur mockée
}

Génération par macros

Tout ce code est produit par le preprocesseur C à partir d'une seule ligne dans le .c :

DECL_CLCC_ARGS_1(void *, malloc, size_t)
//               ^return  ^nom    ^args

Cette macro déplie : la struct de contexte, les fonctions de contrôle (set_control, set_return_value, control_after), et le wrapper complet avec la bonne signature. Il existe une variante DECL_CLCC_ARGS_N pour 0 à 6 arguments.

Côté utilisateur, les macros publiques (clcc_enable_control, clcc_return_value_after...) ne sont que du sucre syntaxique qui appelle ces fonctions générées — le preprocesseur résout clcc_enable_control(malloc) en clcc_malloc_set_control(true) sans aucune indirection à l'exécution.

Ajouter une fonction

Déclarer le prototype dans le header du module :

// modules/stdlib.h
PUBLIC_PROTO_CLCC(void *, malloc)

Et l'implémenter en une ligne dans le .c correspondant :

// src/stdlib.c
DECL_CLCC_ARGS_1(void *, malloc, size_t)

C'est tout — le reste est généré.

Exemple concret

Tester qu'un serveur gère correctement l'échec de socket() à l'initialisation :

#include "libs/clcc/modules/sys/socket.h"
#include "libs/clcc/modules/stdlib.h"

void test_init_handles_socket_failure(void)
{
    // socket() retourne -1 dès le premier appel
    clcc_return_now(socket, -1);

    server_t *srv = server_init(8080);

    assert(srv == NULL);           // init doit échouer proprement
    clcc_disable_control(socket);  // on rend socket au système. Important sinon
                                   // le mock reste actif pour les tests suivants

    // maintenant tester qu'un malloc raté en cours d'init est géré
    clcc_return_now(malloc, NULL);

    srv = server_init(8080);

    assert(srv == NULL);
    clcc_disable_control(malloc);
}

Sans CLCC, ces deux branches de server_init n'auraient pas pu être testées simplement.

API

MacroDescription
clcc_enable_control(fn)Active le mock pour fn
clcc_disable_control(fn)Désactive le mock, revient au comportement réel
clcc_set_return_value(fn, val)Définit la valeur retournée quand le mock est actif
clcc_return_value_after(fn, val, n)Active le mock après n appels réels
clcc_return_now(fn, val)Mock immédiat avec la valeur donnée
clcc_control_after(fn, n)Active le mock après n appels (valeur déjà configurée)

Utilisation dans mes projets Epitech

La lib a évolué au fil des projets de l'année :

Sans CLCC, plusieurs mécanismes de gestion d'erreur auraient été difficilement testables.

Limites connues

La lib utilise write en interne pour ses messages d'erreur — ce qui pose un problème circulaire si write est lui-même surchargé dans les tests. C'est une limitation que j'aurais adressée avec plus de temps, probablement en isolant les writes internes via un fd dédié ou en évitant tout I/O dans le chemin de contrôle.

Avec le recul, j'aurais aussi voulu creuser davantage les mécanismes de linkage : LD_PRELOAD, les sections .got/.plt, le résolveur de symboles — il y a un espace intéressant entre ce que fait CLCC et ce que font des outils comme LD_PRELOAD au niveau système.