Minishell

Questa bash shell permette di:

  • Visualizzare un prompt ogni volta che è in attesa di un comando.
  • Gestire correttamente la history.
  • Gestire correttamente le virgolette singole e doppie.
  • Gestire le redirezioni:
  • Gestire le pipe in modo che l’output di ogni comando sia collegato all’input del comando successivo.
  • Gestire l’espansione delle variabili d’ambiente, incluso il caso di $? per l’ultimo stato di uscita.
  • Gestire i segnali in modalità interattiva.
  • Cercare e lanciare l’eseguibile corretto, basandosi sulla variabile PATH oppure su percorsi relativi/assoluti.
  • Utilizzare custom built-in.

1. Architettura Generale

La shell dovrà avere un ciclo principale che:

  1. Mostra un prompt e attende un nuovo comando dall’utente.
  2. Legge una riga di input (ad esempio, usando la funzione readline(), che fornisce anche una history e la modifica della linea).
  3. Analizza (parsa) l’input: suddivide la stringa in token, gestendo correttamente le virgolette (singole e doppie) e riconoscendo gli operatori di redirezione, le pipe e l’espansione delle variabili d’ambiente.
  4. Costruisce una struttura di comando (o un albero sintattico astratto) che rappresenti i comandi semplici, le pipeline e le redirezioni.
  5. Esegue il comando o i comandi:
    • Verifica se il comando è un builtin (ad esempio, cd, echo, ecc.); in questo caso lo esegue direttamente.
    • Altrimenti, cerca l’eseguibile (usando la variabile PATH oppure percorsi relativi/assoluti) e lo lancia tramite fork() e execve().
  6. Gestisce i segnali (come ctrl-C, ctrl-D, ctrl-) impostando il comportamento corretto.
  7. Pulisce la memoria allocata prima di tornare al prompt.

2. Gestione dell’Input e Visualizzazione del Prompt

  • Visualizzazione del Prompt e Lettura dell’Input:
    Utilizza la funzione readline() per mostrare il prompt e catturare l’input dell’utente. Questa funzione gestisce anche la history in automatico. Esempio:
    char *line = readline("minishell> ");
    if (line == NULL) {  // Ad esempio, quando viene premuto ctrl-D (EOF)
        printf("exit\n");
        exit(0);
    }
    if (*line)
        add_history(line);
    

3. Gestione dei Segnali

  • Variabile Globale per il Segnale:
    Definisci una singola variabile globale (di tipo volatile sig_atomic_t) per registrare il numero del segnale ricevuto. Questa variabile non deve fornire altro tipo di informazione o accesso a strutture dati interne. Ad esempio:
    volatile sig_atomic_t g_signal = 0;
    
    void signal_handler(int signum) {
        g_signal = signum;
        // In modalità interattiva, per ctrl-C potresti voler stampare una nuova riga
        // o impostare un flag per il ciclo principale.
    }
    
  • Impostazione dei Segnali:
    Utilizza sigaction() per impostare il gestore dei segnali per SIGINT (ctrl-C) e SIGQUIT (ctrl-):
    struct sigaction sa;
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART; // Riavvia automaticamente le syscall interrotte
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGQUIT, &sa, NULL);
    
  • Comportamento Atteso:
    • ctrl-C (SIGINT): Quando ricevuto durante la digitazione, deve stampare una nuova riga e rischedulare il prompt.
    • ctrl-D: Se readline() restituisce NULL, la shell deve terminare.
    • ctrl-\ (SIGQUIT): Deve essere ignorato o non produrre alcun effetto.

4. Parsing e Tokenizzazione

Implementa un parser che faccia le seguenti operazioni:

  • Suddividere l’input in token:
    Puoi realizzare una macchina a stati che riconosca:
    • I confini delle parole: usando gli spazi bianchi come separatori.
    • Virgolette singole ('): il contenuto compreso tra due virgolette singole va interpretato letteralmente.
    • Virgolette doppie ("): il contenuto va preso letteralmente, ad eccezione del carattere $ che serve per l’espansione delle variabili.
    • Token speciali: riconosci operatori come <, >, >>, << per le redirezioni e il carattere | per le pipe.
  • Espansione delle Variabili d’Ambiente:
    Quando incontri un token che inizia con $ (oppure $?), sostituisci il token con il valore della variabile d’ambiente (oppure con l’ultimo codice di uscita, che puoi memorizzare in una variabile locale ad ogni esecuzione del comando).

Suggerimento: Scrivi una funzione dedicata (o un insieme di funzioni) che scansiona la stringa di input e costruisce una lista di token (o nodi) che saranno successivamente usati per costruire la struttura dei comandi.


5. Struttura dei Comandi e Sintassi

Definisci una struttura per rappresentare un comando. Ad esempio:

typedef enum {
    CMD_SIMPLE,
    CMD_PIPELINE
} cmd_type_t;

typedef struct s_redir {
    int type;           // ad esempio: IN, OUT, APPEND, HEREDOC
    char *filename;     // il file o, in caso di heredoc, il delimitatore
} redir_t;

typedef struct s_command {
    char **args;        // vettore degli argomenti (argv)
    redir_t *redirs;    // array dinamico degli oggetti di redirezione
    int num_redirs;
} command_t;

typedef struct s_pipeline {
    command_t *commands;  // array dinamico dei comandi della pipeline
    int num_commands;
} pipeline_t;
  • Pipeline:
    Se il comando è una pipeline, puoi memorizzare un array di comandi. Ogni comando potrà avere le proprie redirezioni.
  • Redirezioni:
    Gestisci i token per <, >, >> e <<. Nel caso del heredoc (<<), dovrai chiedere all’utente di inserire le righe fino a quando non viene digitato il delimitatore. Il contenuto del heredoc può essere memorizzato in un file temporaneo o in una pipe da usare nel processo figlio.

6. Esecuzione

A. Builtins

Implementa le seguenti funzioni builtins, ognuna in funzione separata:

  • echo:
    Gestisce l’opzione -n.
  • cd:
    Usa chdir() per cambiare directory; accetta solo percorsi relativi o assoluti.
  • pwd:
    Stampa la directory corrente (usa getcwd()).
  • export:
    Imposta una variabile d’ambiente (puoi usare setenv()).
  • unset:
    Rimuove una variabile d’ambiente (usa unsetenv()).
  • env:
    Stampa tutte le variabili d’ambiente.
  • exit:
    Termina la shell in maniera ordinata.

Quando viene analizzato un comando, controlla se il primo token corrisponde a uno di questi builtins. Se sì, esegui la funzione corrispondente e non esegui un fork().

B. Comandi Esterni

Se il comando non è un builtin, procedi così:

  1. Fork del processo figlio:
    Crea un nuovo processo con fork().

  2. Nel processo figlio:

    • Gestione delle redirezioni:
      • Per <: apri il file in lettura e duplica il descrittore nel file descriptor standard input (STDIN).
      • Per >: apri/crea il file in scrittura (troncandolo) e duplica su STDOUT.
      • Per >>: apri/crea il file in modalità append e duplica su STDOUT.
      • Per << (heredoc): prepara una pipe o un file temporaneo con il contenuto heredoc e redirigi STDIN.
    • Ricerca dell’eseguibile:
      Se il comando non è un percorso assoluto o relativo, utilizza la variabile PATH per individuarlo. Puoi usare funzioni come access() iterando sui percorsi contenuti in PATH.
    • Esecuzione del comando:
      Usa execve() per eseguire il comando. Se l’exec fallisce, stampa un messaggio d’errore e chiudi il processo figlio.
  3. Nel processo padre:

    • Se il comando è in esecuzione in primo piano, aspetta la terminazione del processo figlio con waitpid().
    • Aggiorna la variabile che memorizza l’ultimo stato di uscita (necessaria per l’espansione di $?).
    • Se stai eseguendo una pipeline, prepara le pipe tra i vari processi: crea le pipe prima di forcare e collega l’output standard di un processo all’input standard del successivo.

Esempio di Esecuzione con Pipe

// Supponiamo che pipeline->num_commands >= 1
int prev_pipe_fd[2] = {-1, -1};

for (int i = 0; i < pipeline->num_commands; i++) {
    int pipe_fd[2] = {-1, -1};
    if (i < pipeline->num_commands - 1) {
        if (pipe(pipe_fd) < 0) {
            perror("pipe");
            // Gestisci l'errore
        }
    }
    pid_t pid = fork();
    if (pid == 0) {
        // Processo figlio
        if (i > 0) {
            // Collega l'estremità di lettura della pipe precedente allo STDIN
            dup2(prev_pipe_fd[0], STDIN_FILENO);
            close(prev_pipe_fd[0]);
            close(prev_pipe_fd[1]);
        }
        if (i < pipeline->num_commands - 1) {
            // Collega l'estremità di scrittura della pipe corrente allo STDOUT
            dup2(pipe_fd[1], STDOUT_FILENO);
            close(pipe_fd[0]);
            close(pipe_fd[1]);
        }
        // Applica eventuali redirezioni specifiche per questo comando
        // Esegui il comando
        execve(...);
        perror("execve");
        exit(1);
    } else if (pid < 0) {
        perror("fork");
        // Gestisci l'errore
    }
    // Nel processo padre: chiudi le estremità della pipe utilizzate
    if (i > 0) {
        close(prev_pipe_fd[0]);
        close(prev_pipe_fd[1]);
    }
    prev_pipe_fd[0] = pipe_fd[0];
    prev_pipe_fd[1] = pipe_fd[1];
}
// Attendi la terminazione di tutti i processi figli
while (wait(NULL) > 0)
    ;

7. Espansione delle Variabili d’Ambiente

Durante il parsing:

  • Per token che iniziano con $:
    • Se il token è $?, sostituiscilo con una stringa che rappresenta l’ultimo stato di uscita.
    • Altrimenti, usa getenv() per ottenere il valore della variabile e sostituisci il token (o parte di esso) con il valore corrispondente.
  • All’interno delle stringhe racchiuse tra virgolette doppie, espandi solo il $ e lascia invariati gli altri caratteri speciali.
  • All’interno delle virgolette singole, non effettuare alcuna espansione.

Puoi scrivere una funzione dedicata (ad esempio char *expand_vars(const char *token)) che scorra il token, rilevi i caratteri $ e costruisca una nuova stringa con le sostituzioni appropriate.


8. Gestione della Memoria e Cleanup

  • Memory Leak:
    Assicurati che ogni memoria allocata (per token, strutture dei comandi, array di redirezioni, ecc.) venga liberata con free() una volta eseguito il comando.
  • Readline:
    Libera la memoria restituita da readline() dopo aver elaborato la linea.

Nota: La funzione readline() può generare alcune perdite di memoria che puoi ignorare, ma il codice che scrivi tu non deve avere perdite.


9. Pseudocodice del Main Loop

Un esempio semplificato del ciclo principale potrebbe essere:

int main(void) {
    // Imposta i gestori dei segnali
    struct sigaction sa;
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGQUIT, &sa, NULL);

    // Ciclo principale della shell
    while (1) {
        // Mostra il prompt e leggi l'input
        char *line = readline("minishell> ");
        if (!line) {  // Se viene premuto ctrl-D
            printf("exit\n");
            break;
        }
        if (*line) {
            add_history(line);
            // Parso l'input e costruisco la struttura del comando
            command_t *cmd = parse_input(line);
            if (!cmd) {
                free(line);
                continue;
            }
            // Verifico se si tratta di una pipeline o di un comando semplice
            if (is_pipeline(cmd)) {
                execute_pipeline(cmd);
            } else {
                // Controlla se il comando è un builtin
                if (is_builtin(cmd->args[0])) {
                    execute_builtin(cmd);
                } else {
                    execute_external_command(cmd);
                }
            }
            // Libera la memoria associata alla struttura del comando
            free_command(cmd);
        }
        free(line);
    }
    return 0;
}

Le funzioni parse_input(), is_pipeline(), execute_pipeline(), is_builtin(), execute_builtin(), execute_external_command() e free_command() devono essere implementate separatamente, ciascuna con una responsabilità chiara (ad esempio, parse_input() si occupa della tokenizzazione e del controllo sintattico, mentre execute_external_command() si occupa del fork e dell’exec).


10. Casi particolari

  • Test dei Segnali:
    Assicurati che, durante l’esecuzione dei comandi (specialmente durante le chiamate bloccanti come readline()), i segnali (ctrl-C, ctrl-D, ctrl-) producano il comportamento atteso.
  • Redirezioni e Pipe:
    Prova varie combinazioni, ad esempio una pipeline con più redirezioni o l’utilizzo dell’heredoc.
  • Gestione della Memoria:
    Utilizza strumenti come Valgrind per verificare che la shell non presenti memory leak (tranne quelli eventualmente attribuibili a readline()).