pipex · 42 // TECHNICAL GUIDE
SYS:ONLINE REC // 04
Projet 42 · Branche Système · Unix Process

pipex

// Une réécriture pédagogique du comportement < file1 cmd1 | cmd2 > file2 — la mécanique des pipes shell.

Recoder pipex, c'est plonger dans le triptyque fondateur d'UNIX : fork, pipe, execve. Trois appels système qui, combinés avec dup2 et waitpid, permettent de chaîner N commandes comme le fait le shell chaque jour. Ce guide dissèque l'implémentation fichier par fichier, du fork() initial jusqu'à la libération des pipes, sans oublier le bonus here_doc.

Difficulté
★★★☆☆
Temps estimé
35 – 60 h
Appels système clés
fork · pipe · dup2 · execve
Sortie attendue
./pipex
// Contrainte clé

Le sujet exige une équivalence stricte avec file1 cmd1 | cmd2 file2 pour la partie mandatory. Le bonus ajoute le here_doc (limiter <<) et le chaînage de N commandes avec N-1 pipes. Aucune fonction de la libc en dehors de open, read, write, close, malloc, free, perror, strerror, access, dup, dup2, execve, exit, fork, pipe, unlink, wait, waitpid, wait3, wait4, signal, kill et les fonctions de libft ne peut être appelée.

01

Le projet

pipex reproduit le comportement d'une ligne shell qui enchaîne deux commandes en les connectant par un pipe, avec une redirection d'entrée depuis un fichier et une redirection de sortie vers un autre fichier. Derrière cette apparente simplicité se cache toute la machinerie UNIX de gestion des processus.

Contraintes du sujet PDF

Le PDF du sujet impose un cadre strict :

  • Le programme s'appelle pipex et prend exactement 4 arguments pour la partie mandatory : ./pipex file1 cmd1 cmd2 file2
  • Le comportement doit être strictement équivalent à : < file1 cmd1 | cmd2 > file2
  • Le bonus gère : ./pipex here_doc LIMITER cmd1 cmd2 file équivalent à cmd1 << LIMITER | cmd2 >> file
  • Le bonus gère aussi plusieurs commandes : ./pipex file1 cmd1 cmd2 cmd3 ... cmdn file2
  • Gestion d'erreurs minimale : ne pas crasher sur un fichier inexistant, afficher un message
  • La libft est autorisée
  • Pas de fuite de mémoire, pas de fd non fermé, pas de zombie
// Piège du sujet

Beaucoup d'étudiants pensent que cmd1 doit terminer avant que cmd2 commence. C'est faux : les deux commandes s'exécutent concurrently, exactement comme le shell. Le pipe fait office de tampon entre les deux. C'est ce qu'on doit reproduire avec fork().

Fonctions autorisées

La liste blanche du sujet est volontairement restreinte aux primitives UNIX de manipulation de fichiers, de processus et de descripteurs :

FamilleFonctionsUsage dans pipex
Fichiers / I/Oopen read write close unlink accessOuvrir infile/outfile, lire here_doc, vérifier le PATH
Mémoiremalloc free perror strerrorAllouer args/paths, messages d'erreur
Descripteursdup dup2Rediriger STDIN/STDOUT vers pipes/fichiers
Processusfork exit wait waitpid wait3 wait4 killCréer les children, attendre leur fin
ExécutionexecveRemplacer un child par la commande
CommunicationpipeCréer le canal entre cmd1 et cmd2
SignauxsignalBonus : gérer Ctrl-C pendant here_doc
Persotoutes les fonctions de libftft_strsplit, ft_strjoin, ft_strcmp...

Équivalence avec le shell

La spécification se résume en deux lignes shell :

shell equivalence// mandatory + bonus
$ ./pipex infile "ls -l" "wc -l" outfile
# strictement équivalent à :
$ < infile ls -l | wc -l > outfile

$ ./pipex here_doc EOF "cat" "wc -c" outfile
# strictement équivalent à :
$ cat << EOF | wc -c >> outfile

Notez la différence critique entre les deux : le mode normal utilise O_TRUNC (le outfile est écrasé), tandis que le mode here_doc utilise O_APPEND (on ajoute à la fin). C'est géré dans init_pipex via un test sur argv[1].

// Pour tester l'équivalence

Lancez les deux versions et comparez avec diff. Si votre pipex et le shell produisent le même outfile byte-à-byte, vous êtes bon : diff <(./pipex infile "ls" "wc" out1) <(< infile ls | wc > out2).

02

Les processus UNIX

Tout le projet repose sur une idée simple : chaque commande doit s'exécuter dans son propre processus. Le processus appelant (le parent, ici pipex) ne peut pas se permettre d'appeler execve directement, car cet appel remplace le processus courant — il n'y aurait alors plus personne pour lancer la commande suivante. D'où l'usage systématique de fork().

fork() — cloner le processus

fork() crée une copie exacte du processus appelant. Le child hérite d'une copie de la mémoire, du même ensemble de descripteurs de fichiers (partagés au niveau kernel, mais référencés des deux côtés), du même environnement. La seule différence : la valeur de retour de fork().

CôtéRetour de fork()Action typique
Parentpid > 0 (PID du child)Continuer la boucle, fermer les fds, waitpid
Childpid == 0Rediriger I/O, fermer les fds inutiles, execve
Erreurpid < 0error_exit("fork")
// Diagramme — un fork() produit deux processus jumeaux
fork() appelé │ ┌────────────┴────────────┐ │ │ PARENT CHILD pid = 12345 pid = 0 (continue la boucle) (redirige + execve) │ │ │ ┌─── waitpid ───► │ │ │ (bloquant) │ │ │ │ execve remplace │ │ ▼ le child par la cmd │ │ ┌────────┐ │ │ │ ls │ │ │ │ wc │ │ │ │ cat │ │ │ └───┬────┘ │ │ │ exit(127) si fail │ ◄──────────────────┘ │ (waitpid retourne) ▼ boucle suivante

Le pattern dans main applique exactement ce schéma pour chaque commande :

pipex.c// boucle de fork
i = 0;
while (i < px->cmd_count)
{
    pid = fork();
    if (pid < 0)
        error_exit("fork");
    if (pid == 0)                       /* child */
        run_child(&px, argv[cmd_offset + i], envp, i);
    i++;
}
// Pourquoi fork() et pas juste appeler execve ?

Parce qu'execve ne retourne pas en cas de succès. Si pipex appelait execve pour cmd1, le processus pipex serait remplacé par cmd1 — impossible alors de lancer cmd2. Le fork crée un child sacrifiable : c'est lui qui devient cmd1 via execve, et le parent survit pour lancer cmd2.

execve() — remplacer le processus

execve(path, argv, envp) remplace complètement l'image mémoire du processus appelant par le binaire pointé par path. Le PID ne change pas, les descripteurs ouverts sont conservés (sauf O_CLOEXEC), mais le code, le data, le heap, la stack sont écrasés. execve ne retourne qu'en cas d'échec.

execute.c// execute_cmd
void  execute_cmd(char *cmd, char **envp)
{
    char    **args;
    char    *path;

    args = ft_strsplit(cmd, ' ');
    if (!args || !args[0])
        error_exit("command not found");
    path = find_path(args[0], envp);
    execve(path, args, envp);          /* ne retourne JAMAIS si succès */
    perror(args[0]);                   /* ici = échec, on explique */
    free(path);
    free_split(args);
    exit(127);                       /* code conventionnel : cmd introuvable */
}
// Subtilité importante

Tout ce qui se trouve après execve dans le code n'est exécuté que si execve a échoué. C'est pourquoi on peut y mettre perror et exit(127) sans test : si on arrive là, c'est forcément une erreur.

waitpid() — attendre la fin des enfants

Une fois que le parent a lancé tous ses children par fork, il doit les attendre. Sans cela, les children deviendraient des zombies (processus terminés mais toujours présents dans la table du kernel). waitpid(-1, NULL, 0) bloque jusqu'à ce qu'un child se termine — on l'appelle dans une boucle.

pipex.c// attente des enfants
if (px.infile != -1)
    close(px.infile);                /* le parent ferme TOUT */
close(px.outfile);
close_all_pipes(&px);
i = 0;
while (i < px.cmd_count)
{
    waitpid(-1, NULL, 0);            /* blocage jusqu'à la fin d'un child */
    i++;
}
free_pipes(px.pipes, px.cmd_count - 1);
// Pourquoi waitpid AVANT de fermer les pipes ?

Erreur courante : penser qu'il faut attendre cmd1 avant de lancer cmd2. Non : tous les forks sont lancés d'abord, puis on attend. Sinon le pipe se remplit (buffer kernel de 64 KB) et cmd1 bloque. L'ordre est : (1) fork tous les children, (2) fermer tout dans le parent, (3) waitpid en boucle.

Arbre de processus pour 2 commandes

// Arbre — ./pipex infile "ls" "wc" outfile
pipex (parent, PID 1000) │ ┌─────────┴─────────┐ │ │ fork() n°1 fork() n°2 │ │ ▼ ▼ child 1 (PID 1001) child 2 (PID 1002) │ │ dup2(infile→stdin) dup2(pipe[0]r→stdin) dup2(pipe[0]w→stdout) dup2(outfile→stdout) │ │ execve execve │ │ ▼ ▼ ls wc (image remplacée) (image remplacée) Le parent (1000) waitpid() x2 puis exit(0).
03

Les pipes

Un pipe est un canal de communication unidirectionnel entre deux processus. Il est créé par l'appel système pipe(int fds[2]) qui renvoie deux descripteurs : fds[0] est l'extrémité de lecture (READ_END), fds[1] est l'extrémité d'écriture (WRITE_END). Tout ce qu'on écrit dans fds[1] est lisible depuis fds[0] — dans l'ordre, FIFO.

pipe() et create_pipes()

Pour N commandes, il faut N-1 pipes. La fonction create_pipes alloue un tableau 2D et appelle pipe() pour chacun :

utils.c// create_pipes
int     **create_pipes(int count)
{
    int     **pipes;
    int     i;

    if (count <= 0)                  /* une seule cmd = aucun pipe */
        return (NULL);
    pipes = malloc(sizeof(int *) * count);
    if (!pipes)
        error_exit("malloc");
    i = 0;
    while (i < count)
    {
        pipes[i] = malloc(sizeof(int) * 2);
        if (!pipes[i])
            error_exit("malloc");
        if (pipe(pipes[i]) < 0)
            error_exit("pipe");
        i++;
    }
    return (pipes);
}

La structure t_pipex stocke tout l'état partagé :

pipex.h// structure principale
typedef struct  s_pipex
{
        int             infile;          /* fd du fichier d'entrée (-1 si here_doc) */
        int             outfile;         /* fd du fichier de sortie */
        int             **pipes;         /* tableau [N-1][2] de pipes */
        int             cmd_count;      /* nombre de commandes */
        int             here_doc;       /* 1 si mode here_doc, 0 sinon */
        char    *limiter;                /* délimiteur EOF pour here_doc */
}                               t_pipex;

Le flux de données entre 2 children

// Diagramme — flux entre cmd1 et cmd2 via un pipe
infile pipe[0] outfile ┌─────┐ ┌──────────────┐ ┌─────┐ │ fd │ │ write read │ │ fd │ │ in │ │ [1] [0] │ │ out │ └──┬──┘ └───┬─────┬────┘ └──▲──┘ │ │ │ │ │ child 1 (cmd1) │ │ child 2 (cmd2) │ ┌─────────┐ │ │ ┌─────────┐ │ └──►│ stdin │ │ └────►│ stdin │ │ │ │ │ │ │ │ │ stdout ─┼─────┘ │ stdout ─┼────┘ │ (ls) │ │ (wc) │ └─────────┘ └─────────┘ cmd1 lit infile → écrit dans pipe[0][WRITE_END] cmd2 lit pipe[0][READ_END] → écrit dans outfile

Chaque child ne voit que son stdin et son stdout. Grâce à dup2, le child ne sait pas qu'il lit depuis un pipe plutôt que depuis le terminal — l'abstraction est totale. C'est toute la beauté du design UNIX : les programmes sont agnostiques à la source/destination de leurs données.

Pourquoi fermer les fds inutilisés ?

C'est le piège n°1 de pipex. Quand un child ne ferme pas toutes les extrémités de pipe dont il n'a pas besoin, l'autre child peut bloquer indéfiniment.

// Deadlock classique

Le read() sur l'extrémité de lecture d'un pipe ne renvoie EOF (retour 0) que lorsque toutes les extrémités d'écriture sont fermées. Si le child 2 garde ouvert pipe[0][WRITE_END] « au cas où », le kernel considère qu'un writer est encore potentiellement présent. Donc le read de cmd2 (via wc) attendra pour toujours la prochaine ligne — même si cmd1 a terminé. Résultat : pipex se fige, il faut Ctrl-C.

D'où l'importance de close_all_pipes, appelée dans chaque child juste avant execve :

pipex.c// close_all_pipes
static void     close_all_pipes(t_pipex *px)
{
    int     i;

    i = 0;
    while (i < px->cmd_count - 1)
    {
        close(px->pipes[i][READ_END]);
        close(px->pipes[i][WRITE_END]);
        i++;
    }
}
// Bonne pratique

Le child fait ses dup2 d'abord (qui duplique le fd dans STDIN/STDOUT), puis ferme tout. Comme dup2 crée une nouvelle référence au même canal, fermer l'original n'affecte pas STDIN/STDOUT. L'ordre est crucial.

// Erreur récurrente

Si vous fermez le fd avant le dup2, vous redirigez STDIN vers un fd invalide — comportement indéfini. Toujours dup2 avant close.

04

Redirection I/O

Le shell réalise les redirections <, >, <<, >> en manipulant les descripteurs 0 (STDIN) et 1 (STDOUT). Pipex fait exactement la même chose avec dup2(oldfd, newfd), qui fait pointer newfd vers le même fichier que oldfd.

dup2() — dupliquer un descripteur

dup2(oldfd, newfd) ferme newfd s'il était ouvert, puis le fait pointer sur la même entrée de la table des fichiers que oldfd. Après l'appel, écrire dans newfd équivaut à écrire dans oldfd.

AppelEffetÉquivalent shell
dup2(infile, STDIN_FILENO)STDIN ← infile< infile
dup2(outfile, STDOUT_FILENO)STDOUT → outfile> outfile
dup2(pipe[0][READ_END], STDIN_FILENO)STDIN ← sortie du pipe précédent(connexion entre cmds)
dup2(pipe[i][WRITE_END], STDOUT_FILENO)STDOUT → entrée du pipe suivant(connexion entre cmds)

run_child() — orchestration des redirections

La fonction run_child décide quelles redirections appliquer selon l'index de la commande dans la chaîne. Le premier child lit l'infile, le dernier écrit dans l'outfile, les autres utilisent les pipes :

pipex.c// run_child
static void     run_child(t_pipex *px, char *cmd, char **envp,
                                                int index)
{
    if (index == 0)                                   /* première cmd : infile */
        dup2(px->infile, STDIN_FILENO);
    else                                              /* sinon : pipe précédent */
        dup2(px->pipes[index - 1][READ_END], STDIN_FILENO);
    if (index == px->cmd_count - 1)                    /* dernière cmd : outfile */
        dup2(px->outfile, STDOUT_FILENO);
    else                                              /* sinon : pipe suivant */
        dup2(px->pipes[index][WRITE_END], STDOUT_FILENO);
    if (px->infile != -1)
        close(px->infile);
    close(px->outfile);
    close_all_pipes(px);                              /* ferme TOUT ce qui n'est pas STDIN/STDOUT */
    execute_cmd(cmd, envp);                           /* execve — pas de retour */
}
// Pourquoi dup2() AVANT execve ?

Parce qu'après execve, le code de pipex est remplacé — il est trop tard pour faire quoi que ce soit. Les redirections doivent être en place avant que la nouvelle image (ls, wc, cat...) ne démarre. Heureusement, execve préserve les descripteurs ouverts (sauf O_CLOEXEC), donc ls lit tranquilement son STDIN qui pointe maintenant vers le pipe.

Le flux complet d'une exécution

// Diagramme — ./pipex infile "ls -l" "wc -l" outfile
infile child 1 pipe[0] child 2 outfile ┌─────┐ ┌─────────┐ ┌─────┐ ┌─────────┐ ┌─────┐ │ │ read │ stdin │ write │ w r │ read │ stdin │ write │ │ │ fd ├────────►│ (dup2) ├──────►│ [1] │────────►│ (dup2) ├──────►│ fd │ │ │ │ │ │ [0] │ │ │ │ │ └─────┘ │ stdout │ └─────┘ │ stdout │ └─────┘ │ (dup2) │ │ (dup2) │ └────┬────┘ └────┬────┘ │ execve │ execve ▼ ▼ ls -l wc -l Étapes : 1. pipe(pipes[0]) → pipes[0][0]=r, pipes[0][1]=w 2. fork() → child 1 child 1: dup2(infile, 0) dup2(pipes[0][1], 1) close(infile) close(pipes[0][0]) close(pipes[0][1]) execve("/bin/ls", ["ls","-l"], envp) 3. fork() → child 2 child 2: dup2(pipes[0][0], 0) dup2(outfile, 1) close(infile) close(pipes[0][0]) close(pipes[0][1]) execve("/bin/wc", ["wc","-l"], envp) 4. parent: close(infile) close(outfile) close(pipes[0][0]) close(pipes[0][1]) waitpid() waitpid()

Notez que le parent ferme lui aussi toutes les extrémités de pipe après les forks. C'est essentiel : tant que le parent garde ouvert pipes[0][WRITE_END], le read de child 2 attendra indéfiniment, pensant que le parent pourrait encore écrire.

// Règle d'or

Chaque fd de pipe doit être fermé dans tous les processus qui ne l'utilisent pas directement : le parent, et chaque child sauf celui qui en a besoin (et encore, seulement après le dup2). Comptez vos close() : pour N commandes, il y a 2(N-1) extrémités de pipe, chacune devant être fermée N+1 fois (1 parent + N children).

05

Recherche du PATH

Quand vous tapez ls dans le shell, il ne sait pas immédiatement où trouver le binaire. Il consulte la variable d'environnement $PATH, qui contient une liste de répertoires séparés par :, et cherche un fichier exécutable nommé ls dans chacun. pipex doit faire exactement la même chose : execve exige un chemin absolu (ou relatif), il ne fait aucune recherche dans $PATH.

// Pourquoi on ne peut pas juste passer "ls" à execve ?

Parce que execve prend un chemin de fichier, pas un nom de commande. Si vous passez "ls", le kernel cherche un fichier littéralement nommé ls dans le répertoire courant — et échoue. Il faut donc convertir "ls" en "/bin/ls" (ou "/usr/bin/ls") avant l'appel. C'est le boulot de find_path.

find_path() — résoudre une commande en chemin absolu

execute.c// find_path
char    *find_path(char *cmd, char **envp)
{
    char    *path_env;
    char    **paths;
    char    *tmp;
    char    *full;
    int             i;

    if (cmd[0] == '/' || cmd[0] == '.')     /* chemin absolu/relatif déjà donné */
        return (ft_strdup(cmd));
    path_env = get_path_env(envp);
    if (!path_env)
        return (ft_strdup(cmd));          /* pas de PATH → on tente quand même */
    paths = ft_strsplit(path_env, ':');
    if (!paths)
        return (ft_strdup(cmd));
    i = 0;
    while (paths[i])
    {
        tmp = ft_strjoin(paths[i], "/");        /* "/usr/bin" + "/" */
        full = ft_strjoin(tmp, cmd);           /* + "ls" → "/usr/bin/ls" */
        free(tmp);
        if (access(full, F_OK) == 0)          /* le fichier existe ? */
        {
            free_split(paths);
            return (full);                  /* GAGNÉ */
        }
        free(full);
        i++;
    }
    free_split(paths);
    return (ft_strdup(cmd));              /* rien trouvé → execve échouera proprement */
}

Parsing de $PATH

L'environnement envp est un tableau de chaînes "KEY=VALUE". On cherche celle qui commence par "PATH=", puis on saute les 5 premiers caractères pour récupérer la valeur :

execute.c// get_path_env
static char     *get_path_env(char **envp)
{
    int     i;

    i = 0;
    while (envp[i])
    {
        if (ft_strncmp(envp[i], "PATH=", 5) == 0)
            return (envp[i] + 5);            /* pointe juste après "PATH=" */
        i++;
    }
    return (NULL);                          /* PATH absent de l'environnement */
}

Exemple concret. Si $PATH vaut :

environnement// $PATH typique
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

Alors ft_strsplit(path_env, ':') produit le tableau :

paths[]// après split
paths[0] = "/usr/local/bin"
paths[1] = "/usr/bin"
paths[2] = "/bin"
paths[3] = "/usr/sbin"
paths[4] = "/sbin"
paths[5] = NULL

Et pour la commande "ls", on essaie successivement /usr/local/bin/ls (non), /usr/bin/ls (non), /bin/ls (oui !) — et on retourne ce dernier chemin.

access() — vérifier l'existence d'un fichier

access(path, mode) vérifie si le fichier est accessible selon le mode demandé. F_OK teste juste l'existence, R_OK la lecture, W_OK l'écriture, X_OK l'exécution. Retourne 0 si OK, -1 sinon.

ModeTestUsage typique
F_OKLe fichier existeRecherche dans PATH (pipex)
X_OKExécutable par l'utilisateur courantVérification supplémentaire (bonus)
R_OKLisibleVérifier infile avant open
W_OKInscriptibleVérifier outfile
// Optimisation possible

L'implémentation utilise seulement F_OK (le fichier existe). Une version plus robuste utiliserait X_OK pour s'assurer que le fichier est bien exécutable. Le sujet n'exige pas cette distinction : si execve échoue ensuite (parce que le fichier n'est pas exécutable), le perror gère l'erreur.

// Fuites mémoire

Dans la boucle de find_path, à chaque itération on malloc tmp puis full. Si on oublie le free(tmp) ou le free(full) avant de passer à l'itération suivante, c'est une fuite par répertoire testé. La moulinette détectera ça avec Valgrind.

06

Parsing des commandes

Une commande shell comme grep "hello world" file.txt doit donner trois arguments : grep, hello world (avec l'espace !), et file.txt. La fonction ft_strsplit de la libft ne suffit pas : elle découperait en 4 tokens parce qu'elle ne comprend pas les quotes. C'est pourquoi pipex embarque son propre split_cmd avec gestion des quotes.

// Pourquoi les quotes ?

Une commande comme echo 'hello world' doit produire 2 arguments (echo et hello world), pas 3 (echo, hello, world). Les guillemets regroupent ce qui est à l'intérieur en un seul token, même s'il contient des espaces. Sans ça, on perdrait une fonctionnalité essentielle du shell.

count_tokens() — première passe

La fonction parcourt la chaîne une première fois pour compter le nombre de tokens. Elle maintient un état quote qui vaut 0 (hors quote), ' ou ". Un token commence quand on est hors quote, sur un non-espace, après un espace (ou en début de chaîne).

split.c// count_tokens
static int      count_tokens(char *str)
{
    int             count;
    int             i;
    char    quote;

    count = 0;
    i = 0;
    quote = 0;                                    /* 0 = hors quote */
    while (str[i])
    {
        if (!quote && (str[i] == '\'' || str[i] == '"'))
            quote = str[i];                          /* on entre dans une quote */
        else if (quote && str[i] == quote)
            quote = 0;                              /* on sort de la quote */
        if (!quote && str[i] != ' '
                && (i == 0 || str[i - 1] == ' '))
            count++;                                 /* début de token hors quote */
        i++;
    }
    return (count);
}

extract_token() — deuxième passe

Une fois le compte connu, on alloue le tableau et on extrait chaque token. La logique de quote est identique, mais au lieu de compter, on note le start et on mesure la len jusqu'au prochain séparateur (espace hors quote).

split.c// extract_token
static char     *extract_token(char *str, int *pos)
{
    char    *token;
    int             start;
    int             len;
    char    quote;

    start = *pos;
    quote = 0;
    while (str[*pos] && (!quote && str[*pos] == ' '))
        (*pos)++;                                    /* skip espaces de tête */
    start = *pos;
    while (str[*pos] && (quote || str[*pos] != ' '))
    {
        if (!quote && (str[*pos] == '\'' || str[*pos] == '"'))
            quote = str[*pos];
        else if (quote && str[*pos] == quote)
            quote = 0;
        (*pos)++;
    }
    len = *pos - start;
    token = malloc(sizeof(char) * (len + 1));
    if (!token)
        return (NULL);
    ft_strncpy(token, str + start, len);
    token[len] = '\0';
    return (token);
}

Différence avec ft_strsplit

Le tableau ci-dessous résume la différence de comportement :

Entréeft_strsplit(cmd, ' ')split_cmd(cmd)
"ls -l"[ls, -l][ls, -l]
"echo hello world"[echo, hello, world][echo, hello, world]
"echo 'hello world'"[echo, 'hello, world'][echo, 'hello world']
"grep \"hello world\" file"[grep, "hello, world", file][grep, "hello world", file]
'cat "a b"'4 tokens erronés2 tokens corrects
// Limitation volontaire

L'implémentation actuelle conserve les caractères quote dans le token ('hello world' reste 'hello world' avec les apostrophes). Le shell les enlève. Pour un pipex parfait, il faudrait une étape de plus qui retire les quotes. Le sujet ne l'exige pas strictement, mais certains correcteurs testent ce comportement.

// Note bonus

Une version plus complète gérerait aussi : les échappements \", les variables $VAR dans les doubles quotes, l'expansion ~. Ces features sont hors sujet pour pipex (elles relèvent de Minishell), mais savoir que split_cmd ne fait que la moitié du travail vous prépare pour la suite.

07

Bonus · multi-pipes & here_doc

Le sujet de pipex propose deux bonus indissociables : gérer N commandes chaînées par N-1 pipes, et gérer le mode here_doc qui remplace l'infile par une saisie interactive terminée par un délimiteur. L'implémentation actuelle supporte les deux dès la partie mandatory — c'est la structure t_pipex qui le permet.

Multi-pipes — N commandes

Pour N commandes, on crée N-1 pipes. Le child i lit depuis le pipe i-1 (sauf le premier, qui lit l'infile) et écrit dans le pipe i (sauf le dernier, qui écrit dans l'outfile). C'est exactement ce que run_child implémente.

// Diagramme — ./pipex in "cmd1" "cmd2" "cmd3" out (N=3, 2 pipes)
infile child1 pipe1 child2 pipe2 child3 outfile ┌─────┐ ┌────┐ ┌───┐ ┌────┐ ┌───┐ ┌────┐ ┌─────┐ │ fd │──►│ c1 ├───►│w r├───►│ c2 ├───►│w r├───►│ c3 ├───►│ fd │ └─────┘ │ │ │ │ │ │ │ │ │ │ └─────┘ └────┘ └───┘ └────┘ └───┘ └────┘ │ │ │ │ │ execve (clos) execve (clos) execve ▼ ▼ ▼ cmd1 cmd2 cmd3 Pour N=3, on crée 2 pipes (pipes[0] et pipes[1]). child 0 : stdin=infile, stdout=pipes[0][W] child 1 : stdin=pipes[0][R], stdout=pipes[1][W] child 2 : stdin=pipes[1][R], stdout=outfile

La boucle de main est volontairement générique :

pipex.c// boucle générique N commandes
cmd_offset = px.here_doc ? 3 : 1;
px.pipes = create_pipes(px.cmd_count - 1);
i = 0;
while (i < px.cmd_count)
{
    pid = fork();
    if (pid < 0)
        error_exit("fork");
    if (pid == 0)
        run_child(&px, argv[cmd_offset + i], envp, i);
    i++;
}

Notez que cmd_count est calculé dans init_pipex selon le mode :

  • Mode normal : argc - 2 (on retire infile et outfile)
  • Mode here_doc : argc - 4 (on retire here_doc, le limiter, et outfile, plus on compte en plus)

here_doc — saisie interactive

Le here_doc du shell (<<) permet d'injecter du texte tapé au clavier comme entrée d'une commande, jusqu'à un délimiteur. Dans pipex, on ne crée pas de fichier temporaire — on utilise un pipe pour transporter les données du parent (qui lit stdin) vers le child (qui consommera via STDIN).

// Pourquoi here_doc utilise un pipe ?

Parce que les fichiers temporaires sont sales : il faut choisir un nom unique, le créer, écrire dedans, le rouvrir en lecture, le supprimer à la fin. Beaucoup de points de défaillance. Un pipe est atomique, sans état sur disque, et se ferme naturellement. Le parent écrit dans le pipe pendant la phase de saisie, ferme l'extrémité d'écriture, et le child lit le contenu comme si c'était un fichier.

// Diagramme — here_doc avec pipe
stdin (clavier) │ │ read() char par char ▼ parent (pipex) ┌─────────────────────────┐ │ boucle de lecture │ │ prompt "heredoc> " │ │ read_line() │ │ if line == limiter → │ │ break │ │ else write sur pipe │ └────────────┬────────────┘ │ write(pipe[1], line) ▼ pipe (anonyme) ┌─────────────┐ │ w r │ │ [1] [0] │ └──────┬──────┘ │ read(pipe[0]) ▼ child (cmd1) ┌──────────────┐ │ stdin = r │ │ (dup2) │ │ │ │ execve cmd1 │ └──────────────┘ Le parent ferme pipe[1] après la saisie → EOF sur pipe[0] → cmd1 termine.

L'implémentation :

here_doc.c// handle_here_doc
int     handle_here_doc(char *limiter)
{
    int             pipe_fd[2];
    char    *line;

    if (pipe(pipe_fd) < 0)
        error_exit("pipe");
    while (1)
    {
        ft_putstr_fd("heredoc> ", 1);
        line = read_line();
        if (ft_strcmp(line, limiter) == 0)
        {
            free(line);
            break ;
        }
        write(pipe_fd[WRITE_END], line, ft_strlen(line));
        write(pipe_fd[WRITE_END], "\n", 1);
        free(line);
    }
    close(pipe_fd[WRITE_END]);                 /* TRÈS IMPORTANT : déclenche EOF */
    return (pipe_fd[READ_END]);                /* sera le "infile" du child */
}

La lecture se fait caractère par caractère pour ne pas consommer le retour chariot suivant dans stdin :

here_doc.c// read_line + append_char
static char     *append_char(char *line, char c)
{
    char    *new;
    int             len;

    len = ft_strlen(line);
    new = malloc(sizeof(char) * (len + 2));
    if (!new)
        return (NULL);
    ft_strcpy(new, line);
    new[len]     = c;
    new[len + 1] = '\0';
    free(line);
    return (new);
}

static char     *read_line(void)
{
    char    *line;
    char    buf[2];
    int             ret;

    line = ft_strnew(1);
    buf[1] = '\0';
    ret = read(STDIN_FILENO, buf, 1);
    while (ret > 0 && buf[0] != '\n')
    {
        line = append_char(line, buf[0]);
        ret = read(STDIN_FILENO, buf, 1);
    }
    return (line);
}
// Le close(pipe_fd[WRITE_END]) est CRUCIAL

Sans cette fermeture après la boucle de saisie, le read du child ne recevra jamais EOF — il attendra indéfiniment une nouvelle ligne. C'est exactement le même piège que pour les pipes entre commandes. Si votre here_doc bloque à la fin, vérifiez d'abord ce close.

Mode >> append

Le mode here_doc implique aussi l'append sur l'outfile (équivalent >> du shell). C'est géré dans init_pipex par le choix du flag passé à open :

pipex.c// init_pipex — ouverture différentielle
static void     init_pipex(t_pipex *px, int argc, char **argv)
{
    px->here_doc = 0;
    px->infile = -1;
    if (is_here_doc(argv[1]))
    {
        px->here_doc = 1;
        px->limiter = argv[2];
        px->cmd_count = argc - 4;
        px->infile = handle_here_doc(px->limiter);
        px->outfile = open(argv[argc - 1],
                        O_WRONLY | O_CREAT | O_APPEND, 0644);  /* >> */
    }
    else
    {
        px->cmd_count = argc - 2;
        px->infile = open(argv[1], O_RDONLY);
        px->outfile = open(argv[argc - 1],
                        O_WRONLY | O_CREAT | O_TRUNC, 0644);   /* > */
    }
    if (px->outfile < 0)
        error_exit(argv[argc - 1]);
}
ModeFlag openEffetÉquivalent shell
NormalO_TRUNCÉcrase le contenu existant> outfile
here_docO_APPENDAjoute à la fin>> outfile
// Différence conceptuelle < vs <<

< file ouvre un fichier existant et l'utilise comme STDIN. C'est ce que fait pipex en mode normal. << EOF crée une entrée à la volée en lisant stdin jusqu'à un délimiteur. C'est ce que fait pipex en mode here_doc. La sortie est la même pour la commande (un fd de lecture), mais la source est radicalement différente.

08

Gestion d'erreurs

Un bon pipex doit se comporter comme le shell face aux erreurs : afficher un message clair, libérer ses ressources, et sortir avec un exit code conventionnel. La règle d'or : ne jamais crasher, même sur une entrée absurde.

Cas d'erreurs typiques

// Fichier d'entrée inexistant
open(infile, O_RDONLY) retourne -1. La convention shell : afficher un message et continuer (cmd1 n'aura rien à lire, cmd2 recevra EOF). Pipex utilise perror et met infile = -1 pour ne pas crasher dans run_child.
// Fichier de sortie non accessible
open(outfile, O_WRONLY|O_CREAT) peut échouer (permission, chemin invalide). Pipex appelle error_exit(argv[argc-1]) qui perror + exit(1).
// Commande introuvable
find_path ne trouve rien dans PATH et retourne le nom brut. execve échoue, perror affiche « ls: No such file or directory », le child appelle exit(127). Code 127 = convention POSIX pour « command not found ».
// Permission denied sur un binaire
Le fichier existe mais n'est pas exécutable. execve échoue avec EACCES. perror affiche « Permission denied ». Le child exit avec 126 (convention POSIX).
// Arguments insuffisants
argc < 5 : pipex affiche un usage sur STDERR et retourne 1. C'est géré dès le début de main.
// Échec de fork ou pipe
Rare mais possible (limite de processus). Pipex appelle error_exit("fork") ou error_exit("pipe"), qui perror + exit(1).

Table des exit codes

CodeSignificationQuandSource
0SuccèsToutes les commandes terminent normalementreturn final de main
1Erreur génériqueopen/fork/pipe échoue, error_exiterror_exitexit(1)
126Commande trouvée mais non exécutableexecve retourne EACCES(bonus, pas dans l'implé de base)
127Commande introuvableexecve retourne ENOENTexecute_cmdexit(127)
128 + NTerminé par signal NChild tué par SIGSEGV, SIGPIPE...Kernel, pas pipex
// Pourquoi 127 ?

C'est une convention POSIX ancienne : le shell utilise 127 pour « command not found » et 126 pour « command found but not executable ». Si votre pipex ne fait pas la distinction, le testeur peut s'en plaindre. L'implémentation actuelle retourne toujours 127 en cas d'échec d'execve, ce qui couvre les deux cas mais n'est pas strictement POSIX-compliant.

Pas de zombie processes

Un zombie est un processus terminé mais toujours présent dans la table des processus du kernel, parce que son parent n'a pas encore appelé waitpid. Pour les éviter, pipex appelle waitpid(-1, NULL, 0) dans une boucle, une fois par child forké.

// Piège : oubli d'un waitpid

Si votre boucle fait waitpid une fois au lieu de N fois, vous laissez N-1 zombies. Visible avec ps aux | grep Z. Le correcteur 42 vérifie ça systématiquement. La règle : un waitpid par fork.

La fonction error_exit

utils.c// error_exit
void    error_exit(char *msg)
{
    perror(msg);                       /* "msg: errno_string" sur stderr */
    exit(1);
}

perror est pratique parce qu'elle traduit automatiquement errno en message lisible (« No such file or directory », « Permission denied », « Too many open files »...). Mais elle appelle exit(1) immédiatement, sans libérer la mémoire. Pour les erreurs dans les children, ce n'est pas grave : le kernel récupère toute la mémoire au exit. Pour le parent, c'est plus discutable, mais toléré par le sujet.

// Pour aller plus loin

Une version plus propre distinguerait : (1) erreur fatale du parent → cleanup + exit(1), (2) erreur d'un child → perror + exit(127/126), (3) warning non fatal (infile inexistant) → perror, continuer. L'implémentation actuelle est minimaliste mais suffisante pour passer le sujet.

09

Compilation & tests

Le Makefile du projet est volontairement simple mais conforme aux exigences 42 : all, clean, fclean, re, avec compilation en -Wall -Wextra -Werror.

Le Makefile

Makefile// build rules
NAME            = pipex
CC              = gcc
CFLAGS          = -Wall -Wextra -Werror

SRC_DIR         = src
OBJ_DIR         = obj
INC_DIR         = includes

LIBFT_DIR       = libft
LIBFT_LIB       = $(LIBFT_DIR)/libft.a

SRCS            = $(SRC_DIR)/pipex.c \
                          $(SRC_DIR)/execute.c \
                          $(SRC_DIR)/split.c \
                          $(SRC_DIR)/here_doc.c \
                          $(SRC_DIR)/utils.c

OBJS            = $(SRCS:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)

INC_FLAGS       = -I$(INC_DIR) -I$(LIBFT_DIR)

LIB_FLAGS       = -L$(LIBFT_DIR) -lft

all:            $(NAME)

$(NAME):        $(LIBFT_LIB) $(OBJS)
                        $(CC) $(CFLAGS) $(OBJS) $(LIB_FLAGS) -o $(NAME)

$(LIBFT_LIB):
                        make -C $(LIBFT_DIR)

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
                        $(CC) $(CFLAGS) $(INC_FLAGS) -c $< -o $@

$(OBJ_DIR):
                        mkdir -p $(OBJ_DIR)

clean:
                        rm -rf $(OBJ_DIR)
                        make -C $(LIBFT_DIR) clean 2>/dev/null || true

fclean:         clean
                        rm -f $(NAME)
                        make -C $(LIBFT_DIR) fclean 2>/dev/null || true

re:                     fclean all

.PHONY:         all clean fclean re

Commandes usuelles :

shell// workflow
$ make                  # compile pipex + libft
$ make re               # fclean + all
$ make fclean           # supprime binaire + objets
$ valgrind ./pipex in "ls" "wc" out   # check leaks

Tests d'équivalence avec le shell

Le test ultime : diff entre la sortie de pipex et celle du shell.

test equivalence// mandatory
$ echo "Hello pipex" > infile
$ ./pipex infile "cat" "cat -e" out_mine
$ < infile cat | cat -e > out_shell
$ diff out_mine out_shell && echo "OK ✓"

$ ./pipex infile "ls -l" "wc -l" out_mine
$ < infile ls -l | wc -l > out_shell
$ diff out_mine out_shell && echo "OK ✓"

$ ./pipex infile "grep pipex" "wc -w" out_mine
$ < infile grep pipex | wc -w > out_shell
$ diff out_mine out_shell && echo "OK ✓"
test bonus// here_doc + multi-pipes
$ ./pipex here_doc EOF "cat" "cat -e" out_mine
heredoc> line one
heredoc> line two
heredoc> EOF
$ cat << EOF | cat -e >> out_shell
line one
line two
EOF
$ diff out_mine out_shell && echo "OK ✓"

$ ./pipex infile "cat" "grep a" "wc -l" "cat -e" out_mine
$ < infile cat | grep a | wc -l | cat -e > out_shell
$ diff out_mine out_shell && echo "OK ✓"

Edge cases à tester absolument

// Infile inexistant
./pipex nope "cat" "wc" out → doit afficher « nope: No such file » sur stderr et produire un outfile vide (comme le shell).
// Commande inexistante
./pipex in "cmd_qui_existe_pas" "cat" out → « cmd_qui_existe_pas: No such file or directory », exit 127.
// Chemin absolu
./pipex in "/bin/ls" "cat" out → doit fonctionner sans passer par PATH (test cmd[0] == '/' dans find_path).
// Chemin relatif
./pipex in "./pipex" "cat" out → doit fonctionner (test cmd[0] == '.' dans find_path).
// Quotes
./pipex in "echo 'hello world'" "cat" out → doit préserver « hello world » comme un seul argument.
// Empty infile
touch empty; ./pipex empty "cat" "wc -c" out → out doit contenir « 0\n » (0 octet en entrée).
// Outfile protégé
touch ro; chmod 000 ro; ./pipex in "cat" "cat" ro → doit afficher « ro: Permission denied » et exit 1.
// here_doc + append
Lancer deux fois ./pipex here_doc EOF "cat" "cat" out → out doit contenir les deux contenus concaténés (mode APPEND).

Erreurs courantes

SymptômeCauseFix
pipex se fige après cmd1fd de pipe non fermé dans un childVérifier close_all_pipes dans run_child
pipex se fige en fin d'exécutionParent a gardé une extrémité de pipe ouverteclose_all_pipes dans le parent aussi
here_doc ne termine pasOubli du close(pipe_fd[WRITE_END])Toujours fermer WRITE_END après la boucle
Commande introuvable alors qu'elle existePATH non trouvé ou mal splittéVérifier get_path_env et le +5
Zombie processes visibleswaitpid pas appelé assez de foisUne boucle waitpid par fork
Fuite mémoire sur cmd non trouvéefull et tmp non libérés dans find_pathfree(tmp) et free(full) à chaque itération
Outfile écrasé alors qu'il devrait être appendéMauvais flag dans init_pipexO_APPEND si here_doc, O_TRUNC sinon
Quotes casséesUtilisation de ft_strsplit au lieu de split_cmdBrancher split_cmd dans execute_cmd
// Workflow recommandé

1. Implémentez le cas mandatory (2 commandes) en dur. Faites passer diff avec < in cmd1 | cmd2 > out. 2. Généralisez à N commandes — c'est juste une boucle. 3. Ajoutez le here_doc. 4. Testez les edge cases un par un. Valgrind après chaque étape — c'est la seule façon de détecter les fuites dans les children avant que le testeur ne les voie.

// Le test ultime

Si votre pipex passe le pipex-tester de gmarcha et la moulinette-style du correcteur, vous avez gagné. La moulinette teste la conformité byte-à-byte avec le shell sur des dizaines de cas — y compris les here_doc, les multi-pipes, et les erreurs. Il n'y a pas de raccourci : il faut que chaque appel système soit à sa place, chaque fd soit fermé, chaque exit code soit correct. Bon courage, androïde.