← Programación en C para Sistemas Operativos Bloque 5 · Cadenas y arreglos · Tema 22 de 42

Tema 22

Manejo seguro de cadenas

El tema anterior terminó con un diagnóstico: strcpy, strcat y sprintf no conocen el tamaño del destino, y un dato externo más largo de lo previsto pisa la memoria vecina. Este tema es el tratamiento. Veremos cómo escribir con un límite usando snprintf y, sobre todo, cómo saber si el texto se recortó, porque un recorte silencioso también es un error. Veremos por qué strncpy y strncat, que parecen las versiones seguras, tienen trampas propias; cómo separar una línea en campos con strtok_r y strsep sin perder los vacíos; y cómo convertir texto en números con strtol sin aceptar basura. Al final, corregimos el programa que dejamos con un strcat peligroso y escribimos un lector de /etc/passwd que no confía en nada de lo que lee.

1. La regla: el tamaño viaja con el buffer

Todos los desbordamientos del tema 21 tienen la misma causa: la función que escribía recibía dónde escribir, pero no cuánto lugar había. La corrección es la regla que ya vimos con fgets en el tema 10 y con los arreglos en el tema 11: toda función que escribe en un buffer recibe también su tamaño, y lo respeta. Pero respetar el tamaño crea una pregunta nueva, que es la que separa el código seguro del que solo parece seguro: ¿qué pasa cuando el texto no cabe?

Hay tres respuestas posibles, y cada función de este tema elige una:

Cuando no cabe…FuncionesConsecuencia
Escribe igual, fuera del bufferstrcpy, strcat, sprintf, getsDesbordamiento: comportamiento indefinido. Solo con datos cuyo largo está garantizado.
Recorta y deja una cadena válida, e informasnprintf, strlcpy, strlcat, fgetsSeguro, si el programa mira lo que informan.
Recorta y puede dejar algo que no es una cadenastrncpyUn arreglo sin terminador, que desborda en la lectura siguiente.

El objetivo del tema es usar siempre funciones de la segunda fila, y tratar su aviso de recorte como un error que el programa debe manejar.

2. snprintf: escribir con límite

snprintf(buf, tam, formato, …) funciona como printf, pero escribe en buf y nunca más de tam bytes, incluido el terminador, que siempre agrega si tam no es cero. Su valor de retorno es lo más importante: la cantidad de caracteres que habría escrito si hubiera tenido lugar, sin contar el \0. No lo que escribió, sino lo que necesitaba.

limite.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    char ruta[24];
    int n;

    n = snprintf(ruta, sizeof ruta, "/proc/%ld/status", (long) getpid());
    printf("n = %2d  ruta = \"%s\"\n", n, ruta);

    const char *usuario = "ana", *archivo = "programa.conf";
    n = snprintf(ruta, sizeof ruta, "/home/%s/.config/%s", usuario, archivo);
    printf("n = %2d  ruta = \"%s\"\n", n, ruta);
    if ((size_t) n >= sizeof ruta)
        printf("        recortada: hacían falta %d bytes y había %zu\n", n + 1, sizeof ruta);

    n = snprintf(NULL, 0, "/home/%s/.config/%s", usuario, archivo);
    printf("snprintf(NULL, 0, ...) = %d\n", n);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o limite limite.c
limite.c: In function ‘main’:
limite.c:14:55: warning: ‘%s’ directive output truncated writing 13 bytes into a region of size 6 [-Wformat-truncation=]
   14 |     n = snprintf(ruta, sizeof ruta, "/home/%s/.config/%s", usuario, archivo);
      |                                                       ^~
limite.c:14:9: note: ‘snprintf’ output 32 bytes into a destination of size 24
$ ./limite
n = 16  ruta = "/proc/527/status"
n = 31  ruta = "/home/ana/.config/progr"
        recortada: hacían falta 32 bytes y había 24
snprintf(NULL, 0, ...) = 31

La primera llamada entró completa: 16 caracteres en 24 bytes. La segunda necesitaba 31 caracteres más el terminador, y el arreglo tiene 24: snprintf escribió los 23 primeros y el \0, y devolvió 31. De ahí sale la comprobación que acompaña a toda llamada:

el patrón de snprintf
int n = snprintf(buf, sizeof buf, formato, …);
if (n < 0)                           /* error de formato o de codificación: raro, pero posible */
    …
else if ((size_t) n >= sizeof buf)  /* no cabía: buf tiene un texto recortado */
    …

La condición es >= y no >, porque si n es igual al tamaño, faltó el lugar del terminador. La conversión a size_t evita comparar un int con un valor sin signo, que -Wextra señala; se hace después de descartar el negativo. Y la tercera llamada muestra un uso particular: con tamaño 0, snprintf no escribe nada, ni siquiera mira buf, y solo devuelve cuánto haría falta. Es la forma de medir un texto formateado antes de reservar memoria para él, algo que haremos con malloc en el tema 28.

Esta vez, además, el compilador avisó: con las cadenas a la vista, -Wformat-truncation, incluida en -Wall, calculó que la salida mediría 32 bytes y el destino 24. Cuando los datos llegan de afuera, el compilador no puede saberlo, y la comprobación de n es la única defensa.

3. Un recorte también es un error

Es tentador pensar que con snprintf el problema está resuelto: no hay desbordamiento, y lo peor que puede pasar es un texto incompleto. Pero un texto incompleto puede ser otro texto válido, con otro significado. Esta función arma el nombre de un archivo temporal que el programa va a borrar:

temporal.c
#include <stdio.h>
#include <string.h>

/* versión ingenua: ignora el recorte */
static void ruta_temporal_mal(char *buf, size_t tam, const char *dir, const char *nombre)
{
    snprintf(buf, tam, "%s/%s.tmp", dir, nombre);
}

/* versión correcta: devuelve 0 si la ruta entró completa, -1 si no */
static int ruta_temporal(char *buf, size_t tam, const char *dir, const char *nombre)
{
    int n = snprintf(buf, tam, "%s/%s.tmp", dir, nombre);

    if (n < 0 || (size_t) n >= tam)
        return -1;
    return 0;
}

int main(int argc, char *argv[])
{
    char ruta[32];
    const char *nombre = argc > 1 ? argv[1] : "informe";

    ruta_temporal_mal(ruta, sizeof ruta, "/home/ana/datos", nombre);
    printf("versión ingenua: se borraría \"%s\"\n", ruta);

    if (ruta_temporal(ruta, sizeof ruta, "/home/ana/datos", nombre) != 0)
        fprintf(stderr, "versión correcta: nombre demasiado largo, no se borra nada\n");
    else
        printf("versión correcta: se borraría \"%s\"\n", ruta);
    return 0;
}
terminal
$ ./temporal
versión ingenua: se borraría "/home/ana/datos/informe.tmp"
versión correcta: se borraría "/home/ana/datos/informe.tmp"
$ ./temporal informe_trimestral
versión ingenua: se borraría "/home/ana/datos/informe_trimest"
versión correcta: nombre demasiado largo, no se borra nada

Con un nombre largo, el recorte se comió justamente el sufijo .tmp, y la versión ingenua borraría un archivo cualquiera del usuario: si existe uno llamado informe_trimest, se pierde. No hubo desbordamiento ni comportamiento indefinido; el programa hizo exactamente lo que dice el código, y el código está mal. Con rutas, órdenes o nombres de usuario, un texto recortado es un texto equivocado. Lo correcto casi siempre es lo que hace ruta_temporal: detectar el recorte e informarlo como error, para que quien llama decida. Recortar a propósito tiene sentido solo cuando el texto es para mostrar, como un mensaje o una columna de ancho fijo.

4. Armar una cadena por partes

Para armar un texto con un número variable de partes, se llama a snprintf en un bucle, escribiendo cada vez a continuación de lo anterior. El patrón habitual guarda en pos cuánto se escribió, y le pasa a cada llamada el lugar que queda, tam - pos. Es fácil equivocarse justamente en el caso del recorte:

lista.c
#include <stdio.h>
#include <string.h>

static const int pids[] = {1, 845, 1532, 2001, 30117, 30118, 31999, 32768};
enum { NPIDS = sizeof pids / sizeof pids[0] };

/* MAL: si una llamada se recorta, pos queda más allá del final */
static void lista_mal(char *buf, size_t tam)
{
    size_t pos = 0;

    for (int i = 0; i < NPIDS; i++)
        pos += (size_t) snprintf(buf + pos, tam - pos, i ? ",%d" : "%d", pids[i]);
}

/* BIEN: se detiene en el primer recorte; devuelve 0 o -1 */
static int lista(char *buf, size_t tam)
{
    size_t pos = 0;

    buf[0] = '\0';
    for (int i = 0; i < NPIDS; i++) {
        int n = snprintf(buf + pos, tam - pos, i ? ",%d" : "%d", pids[i]);
        if (n < 0 || (size_t) n >= tam - pos)
            return -1;                    /* no cabe: buf queda recortado pero terminado */
        pos += (size_t) n;
    }
    return 0;
}

int main(int argc, char *argv[])
{
    char buf[24];

    if (lista(buf, sizeof buf) != 0)
        printf("lista: recortada a \"%s\"\n", buf);
    else
        printf("lista: \"%s\"\n", buf);

    if (argc > 1 && strcmp(argv[1], "mal") == 0) {
        lista_mal(buf, sizeof buf);
        printf("lista_mal: \"%s\"\n", buf);
    }
    return 0;
}
terminal · sin optimizar, con AddressSanitizer y con -O2
$ ./lista mal
lista: recortada a "1,845,1532,2001,30117,3"
lista_mal: "1,845,1532,2001,30117,3"
*** stack smashing detected ***: terminated
Aborted
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -o lista lista.c && ./lista mal
lista: recortada a "1,845,1532,2001,30117,3"
=================================================================
==538==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7252a30d003b at pc 0x7652a5ef1c56 bp 0x7fff069cc350 sp 0x7fff069cbaf0
WRITE of size 7 at 0x7252a30d003b thread T0
    #0 0x7652a5ef1c55 in vsnprintf ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1739
    #1 0x7652a5ef3f34 in snprintf ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1812
    #2 0x601d861314e9 in lista_mal /home/ana/c/tema22/lista.c:13
…
    [32, 56) 'buf' (line 33) <== Memory access at offset 59 overflows this variable
$ gcc -std=c17 -Wall -Wextra -g -O2 -o lista lista.c && ./lista mal
lista: recortada a "1,845,1532,2001,30117,3"
*** buffer overflow detected ***: terminated
Aborted

La versión con snprintf desbordó el buffer, y así es como pasa. Las cinco primeras partes ocupan 21 bytes. La sexta, ,30118, necesita 6 caracteres y quedan 3 bytes: snprintf escribe ,3 y el terminador, como debe, y devuelve 6. lista_mal suma ese 6 sin mirarlo, y pos pasa a valer 27, más allá del final del arreglo de 24. En la vuelta siguiente, tam - pos es 24 − 27 en size_t: no −3, sino 18 446 744 073 709 551 613. snprintf recibe un «tamaño» enorme y escribe ,31999 y su terminador, 7 bytes, a partir del byte 27, fuera del arreglo. Es exactamente lo que dice AddressSanitizer: una escritura de 7 bytes en el desplazamiento 59 del marco, que es buf, en el 32, más 27.

El arreglo buf de 24 bytes contiene 1, ,845, ,1532, ,2001, ,30117 y el recorte ,3 con su terminador, que llena hasta el byte 23. snprintf devolvió 6 y pos quedó en 27, tres bytes después del final. La llamada siguiente recibe como tamaño 24 menos 27, que en size_t es un número enorme, y escribe ,31999 y ,32768 a partir del byte 27, fuera del arreglo char buf[24] fuera del arreglo 1 ,845 ,1532 ,2001 ,30117 ,3\0 ,31999,32768\0 0 21 24 27 «,30118» necesitaba 6 bytes y había 3: snprintf devolvió 6 y pos pasó de 21 a 27 siguiente llamada: tam - pos = 24 - 27 = 18446744073709551613 en size_t snprintf cree que tiene lugar de sobra y escribe a partir de buf + 27
El valor de retorno de snprintf es lo que necesitaba, no lo que escribió. Sumarlo sin comprobarlo deja pos fuera del arreglo.

Hay un detalle más inquietante en la primera ejecución: lista_mal mostró el mismo texto recortado que lista, como si todo estuviera bien. Las escrituras fuera de buf no cambiaron su contenido, sino la memoria de al lado, y el daño se descubrió recién al volver de main. Con -O2, _FORTIFY_SOURCE lo detectó antes: __snprintf_chk conoce el tamaño real de buf y aborta si el que recibe es mayor. La corrección, en lista, es comprobar cada llamada antes de sumar.

5. Las trampas de strncpy

strncpy(dst, src, n) tiene un nombre que invita a pensar en un strcpy con límite. No lo es. Fue diseñada para otra cosa, los campos de largo fijo de las primeras versiones de Unix, como los nombres de archivo de 14 bytes en los directorios, que se guardaban sin terminador si ocupaban el campo entero. Su comportamiento responde a ese uso:

copias.c
#include <stdio.h>
#include <string.h>

static void mostrar(const char *nombre, const char *s, size_t n)
{
    printf("%-6s", nombre);
    for (size_t i = 0; i < n; i++)
        printf(s[i] == '\0' ? " \\0" : "  %c", s[i]);
    printf("\n");
}

int main(int argc, char *argv[])
{
    const char *largo = argc > 1 ? argv[1] : "maximiliano";
    char corto[8];
    char amplio[12];

    strncpy(amplio, "ana", sizeof amplio);          /* rellena con ceros */
    mostrar("amplio", amplio, sizeof amplio);

    strncpy(corto, largo, sizeof corto);            /* no cabe: sin '\0' */
    mostrar("corto", corto, sizeof corto);
    printf("como cadena: \"%s\"\n", corto);

    strncpy(corto, largo, sizeof corto - 1);        /* la forma segura */
    corto[sizeof corto - 1] = '\0';
    mostrar("corto", corto, sizeof corto);
    printf("como cadena: \"%s\"\n", corto);
    return 0;
}
terminal
$ ./copias
amplio  a  n  a \0 \0 \0 \0 \0 \0 \0 \0 \0
corto   m  a  x  i  m  i  l  i
como cadena: "maximiliana"
corto   m  a  x  i  m  i  l \0
como cadena: "maximil"
$ gcc -std=c17 -Wall -Wextra -g -O2 -o copias copias.c
copias.c: In function ‘main’:
copias.c:21:5: warning: ‘__builtin_strncpy’ output may be truncated copying 8 bytes from a string of length 11 [-Wstringop-truncation]
   21 |     strncpy(corto, largo, sizeof corto);            /* no cabe: sin '\0' */
      |     ^

La segunda línea es la trampa: corto quedó con 8 letras y ningún terminador. Al mostrarlo como cadena, printf siguió leyendo por la pila y encontró, justo después, el arreglo amplio, con ana y su \0: el resultado fue «maximiliana», un nombre que no está en ninguna parte del programa. Es el mismo error de sinfin.c del tema 21, ahora causado por la función que se suponía segura. Con -O2, gcc lo advierte con -Wstringop-truncation.

El relleno, por su parte, es un costo innecesario: strncpy(buf, "a", 4096) escribe 4096 bytes para copiar uno. La forma correcta de usar strncpy como copia con límite son las dos líneas del final de copias.c: copiar tam - 1 bytes como máximo y escribir el terminador a mano. Aun así, el resultado no dice si hubo recorte. Por eso la recomendación general es no usar strncpy para cadenas, y reservarla para lo que fue hecha: campos de largo fijo, que no son cadenas, en formatos binarios y estructuras del sistema, como los registros de sesiones de /var/run/utmp.

6. strncat y su límite engañoso

strncat(dst, src, n) sí escribe siempre el terminador, pero su n no es el tamaño del destino, como en snprintf: es la cantidad máxima de caracteres a agregar. El destino tiene que tener lugar para lo que ya contiene, más n caracteres, más el terminador. El error natural es pasarle el tamaño del destino:

agregar.c
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    const char *nombre = argc > 1 ? argv[1] : "mundo";
    char saludo[16] = "Hola, ";

    if (argc > 2)                         /* ./agregar nombre mal */
        strncat(saludo, nombre, sizeof saludo);     /* MAL: n no es el tamaño */
    else
        strncat(saludo, nombre, sizeof saludo - strlen(saludo) - 1);
    printf("\"%s\" (%zu)\n", saludo, strlen(saludo));
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o agregar agregar.c
agregar.c: In function ‘main’:
agregar.c:10:9: warning: ‘strncat’ specified bound 16 equals destination size [-Wstringop-overflow=]
   10 |         strncat(saludo, nombre, sizeof saludo);     /* MAL: n no es el tamaño */
      |         ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ ./agregar Maximiliano_Rodriguez
"Hola, Maximilia" (15)
$ ./agregar Maximiliano_Rodriguez mal
"Hola, Maximiliano_Rodr" (22)

Con el límite mal calculado, strncat agregó 16 caracteres a los 6 que había, y dejó una cadena de 22 caracteres, 23 bytes, en un arreglo de 16. El programa terminó normalmente. gcc reconoce el patrón y lo advierte, incluso sin optimizar, y con -O2 el programa aborta con buffer overflow detected. La cuenta correcta, sizeof saludo - strlen(saludo) - 1, es incómoda y fácil de equivocar, y además, como en strncpy, el resultado no dice si hubo recorte.

7. strlcpy y strlcat

Los programadores de OpenBSD diseñaron en 1998 dos funciones con la interfaz que strncpy y strncat deberían haber tenido: strlcpy(dst, src, tam) y strlcat(dst, src, tam) reciben el tamaño total del destino, como snprintf, siempre dejan una cadena terminada, y devuelven el largo del texto que intentaron crear. glibc las rechazó durante 25 años, con el argumento de que invitan a ignorar los recortes, y finalmente las incorporó en su versión 2.38, de 2023, cuando ya estaba decidido que POSIX las adoptaría, como hizo en su edición de 2024. En glibc se declaran con _DEFAULT_SOURCE:

lcopia.c
#define _DEFAULT_SOURCE                   /* strlcpy y strlcat, glibc 2.38 o posterior */
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    const char *nombre = argc > 1 ? argv[1] : "ana";
    char saludo[16];
    size_t n;

    n = strlcpy(saludo, "Hola, ", sizeof saludo);
    n = strlcat(saludo, nombre, sizeof saludo);
    if (n >= sizeof saludo)
        printf("recortado: hacían falta %zu bytes\n", n + 1);
    printf("\"%s\" (%zu)\n", saludo, strlen(saludo));
    return 0;
}
terminal
$ ./lcopia
"Hola, ana" (9)
$ ./lcopia Maximiliano_Rodriguez
recortado: hacían falta 28 bytes
"Hola, Maximilia" (15)

La comprobación es la misma que con snprintf: si el valor devuelto es mayor o igual que el tamaño, hubo recorte. La tabla compara todas las formas de copiar una cadena:

FunciónEl límite es¿Termina siempre?¿Avisa del recorte?Uso
strcpy, strcatNo haySíNo: desbordaSolo si el largo está garantizado.
strncpyBytes a escribirNoNoCampos de largo fijo, no cadenas.
strncatCaracteres a agregarSíNoMejor evitarla.
strlcpy, strlcatTamaño del destinoSíSí, con el retornoCopias simples; glibc 2.38 o posterior.
snprintfTamaño del destinoSíSí, con el retornoTodo lo demás; disponible en todas partes.
memcpyBytes exactosSolo si se copia el \0—Cuando los largos ya se midieron.

En la práctica, snprintf con la comprobación de la sección 2 resuelve casi todos los casos, y es la que usaremos en el resto del curso: snprintf(dst, tam, "%s", src) es una copia con límite, y snprintf(dst, tam, "%s%s", a, b), una concatenación. El anexo K de C11 propone otra familia, strcpy_s y compañía, pero es opcional, y glibc no la implementa.

8. Separar con strtok

Muchos datos del sistema son líneas con campos separados por un carácter: /etc/passwd usa :, PATH también, las opciones de montaje usan ,. La función estándar para separarlos es strtok(s, delimitadores). La primera llamada recibe la cadena; las siguientes reciben NULL, y continúan donde quedó la anterior. Cada llamada devuelve el próximo campo, o NULL cuando no quedan.

campos.c
#define _DEFAULT_SOURCE                   /* strsep */
#include <stdio.h>
#include <string.h>

int main(void)
{
    char linea[] = "ana:x:1000:1000::/home/ana:/bin/bash";
    char copia[sizeof linea];
    int i;

    memcpy(copia, linea, sizeof linea);

    printf("con strtok:\n");
    i = 0;
    for (char *campo = strtok(linea, ":"); campo != NULL; campo = strtok(NULL, ":"))
        printf("  %d: \"%s\"\n", i++, campo);

    printf("con strsep:\n");
    i = 0;
    char *resto = copia;
    char *campo;
    while ((campo = strsep(&resto, ":")) != NULL)
        printf("  %d: \"%s\"\n", i++, campo);
    return 0;
}
terminal
$ ./campos
con strtok:
  0: "ana"
  1: "x"
  2: "1000"
  3: "1000"
  4: "/home/ana"
  5: "/bin/bash"
con strsep:
  0: "ana"
  1: "x"
  2: "1000"
  3: "1000"
  4: ""
  5: "/home/ana"
  6: "/bin/bash"

Una línea de /etc/passwd tiene siempre 7 campos: nombre, contraseña, UID, GID, descripción, directorio personal e intérprete. Aquí la descripción está vacía, y strtok devolvió solo 6: trata una serie de delimitadores seguidos como uno solo, y saltea los campos vacíos. Un programa que tome el campo 5 como directorio personal recibiría /bin/bash. Ese comportamiento es el adecuado para separar palabras por espacios, donde dos espacios seguidos no forman una palabra vacía, y el inadecuado para registros con posiciones fijas.

Además, strtok tiene tres características que hay que conocer:

La cadena a:b::c separada con strsep: cada dos puntos se reemplaza por un terminador nulo, y resultan cuatro campos, a, b, una cadena vacía y c, que son punteros dentro del mismo arreglo. strtok, en cambio, saltea los delimitadores seguidos y devuelve solo tres campos: a, b y c antes: "a:b::c" a : b : : c \0 después de strsep: cuatro campos en el mismo arreglo a \0 b \0 \0 c \0 "a" "b" "" "c" strtok devolvería solo "a", "b" y "c": saltea el campo vacío
Separar no copia nada: cada delimitador se vuelve un terminador, y cada campo es un puntero dentro de la cadena original.

9. strtok_r y strsep

La variable oculta de strtok tiene una consecuencia seria: no se pueden separar dos cadenas a la vez. Si mientras se recorren los campos de una línea se llama a strtok sobre otra, o sobre uno de los campos, la segunda separación pisa el estado de la primera. Este programa separa opciones de montaje, primero por comas y después cada opción por =:

anidado.c
#define _POSIX_C_SOURCE 200809L          /* strtok_r es de POSIX */
#include <stdio.h>
#include <string.h>

static void con_strtok(char *opciones)
{
    for (char *par = strtok(opciones, ","); par != NULL; par = strtok(NULL, ",")) {
        char *clave = strtok(par, "=");
        char *valor = strtok(NULL, "=");
        printf("  %s -> %s\n", clave, valor ? valor : "(sin valor)");
    }
}

static void con_strtok_r(char *opciones)
{
    char *estado_pares, *estado_par;

    for (char *par = strtok_r(opciones, ",", &estado_pares); par != NULL;
         par = strtok_r(NULL, ",", &estado_pares)) {
        char *clave = strtok_r(par, "=", &estado_par);
        char *valor = strtok_r(NULL, "=", &estado_par);
        printf("  %s -> %s\n", clave, valor ? valor : "(sin valor)");
    }
}

int main(void)
{
    char a[] = "rw,uid=1000,gid=1000,mode=755";
    char b[sizeof a];

    memcpy(b, a, sizeof a);
    printf("strtok:\n");
    con_strtok(a);
    printf("strtok_r:\n");
    con_strtok_r(b);
    return 0;
}
terminal
$ ./anidado
strtok:
  rw -> (sin valor)
strtok_r:
  rw -> (sin valor)
  uid -> 1000
  gid -> 1000
  mode -> 755

La versión con strtok procesó la primera opción y terminó: la llamada interna strtok(par, "=") reemplazó el estado del bucle externo por el de "rw", que ya estaba agotado, y la siguiente strtok(NULL, ",") devolvió NULL. No hubo error ni advertencia; simplemente faltan tres opciones. El mismo problema aparece si una función llamada dentro del bucle usa strtok por su cuenta, y con varios hilos, que comparten esa única variable, como veremos en el curso de Procesos e Hilos.

strtok_r, de POSIX, es igual pero recibe un tercer argumento: la dirección de un char * donde guardar el estado. Cada separación tiene su propia variable, estado_pares y estado_par, y no interfieren. La _r significa reentrante: una función que se puede usar de nuevo mientras una llamada anterior está en curso, porque no guarda nada oculto. Es el patrón de los punteros a punteros del tema 19: la función actualiza una variable de quien llama.

strsep(&resto, delimitadores), de BSD, disponible en glibc con _DEFAULT_SOURCE, usa la misma idea con una interfaz más simple: el estado es el propio puntero resto, que avanza hasta después del delimitador en cada llamada, y queda en NULL al terminar. Su diferencia importante es la que mostró campos.c: devuelve los campos vacíos. La elección queda así:

FunciónEstadoDelimitadores seguidosOrigenUsar para
strtokOculto, uno soloSe unenC estándarNada nuevo: strtok_r la reemplaza.
strtok_rEn una variable de quien llamaSe unenPOSIXPalabras separadas por blancos.
strsepEn una variable de quien llamaCampos vacíosBSD, glibcRegistros con campos fijos, como /etc/passwd.

Las tres modifican la cadena. Cuando el texto no se puede modificar, como una literal o el resultado de getenv, se recorre con strchr y %.*s, como hicimos con PATH en el tema 21.

10. Convertir con strtol

Después de separar los campos, muchos hay que convertirlos en números: un PID, un UID, un permiso en octal. La función para enteros es strtol(texto, &fin, base), de stdlib.h. Devuelve un long e informa tres cosas más: en fin, dónde dejó de leer; en errno, si el número no cabía en un long; y, con fin igual a texto, que no había ningún número. Este programa la prueba con una serie de textos:

strtol.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

static void probar(const char *texto, int base)
{
    char *fin;

    errno = 0;
    long valor = strtol(texto, &fin, base);
    printf("%-24s base %2d  valor %20ld  leídos %2td  resto \"%s\"%s\n",
           texto, base, valor, fin - texto, fin, errno == ERANGE ? "  ERANGE" : "");
}

int main(void)
{
    probar("42", 10);
    probar("  -17", 10);
    probar("+7", 10);
    probar("42abc", 10);
    probar("abc", 10);
    probar("", 10);
    probar("010", 10);
    probar("010", 0);
    probar("0x1F", 0);
    probar("0x1F", 10);
    probar("ff", 16);
    probar("755", 8);
    probar("99999999999999999999", 10);
    probar("-99999999999999999999", 10);
    return 0;
}
terminal
$ ./strtol
42                       base 10  valor                   42  leídos  2  resto ""
  -17                    base 10  valor                  -17  leídos  5  resto ""
+7                       base 10  valor                    7  leídos  2  resto ""
42abc                    base 10  valor                   42  leídos  2  resto "abc"
abc                      base 10  valor                    0  leídos  0  resto "abc"
                         base 10  valor                    0  leídos  0  resto ""
010                      base 10  valor                   10  leídos  3  resto ""
010                      base  0  valor                    8  leídos  3  resto ""
0x1F                     base  0  valor                   31  leídos  4  resto ""
0x1F                     base 10  valor                    0  leídos  1  resto "x1F"
ff                       base 16  valor                  255  leídos  2  resto ""
755                      base  8  valor                  493  leídos  3  resto ""
99999999999999999999     base 10  valor  9223372036854775807  leídos 20  resto ""  ERANGE
-99999999999999999999    base 10  valor -9223372036854775808  leídos 21  resto ""  ERANGE

Cada fila enseña una regla:

La familia tiene una función para cada tipo: strtoll para long long, strtoul y strtoull sin signo, strtod para double. Para tipos más chicos, como int, no hay función propia: se convierte con strtol y se comprueba el rango, como hizo leer_entero en el tema 10.

11. Por qué no atoi

atoi, atol y atof son las funciones de conversión más antiguas y más usadas en ejemplos, y no informan nada:

terminal · atoi.c: atoi sobre seis textos, y strtoul sobre "-1"
$ ./atoi
atoi("0") = 0
atoi("abc") = 0
atoi("") = 0
atoi("12abc") = 12
atoi("4294967297") = 1
atoi("-1") = -1
strtoul("-1") = 18446744073709551615

Para atoi, un texto sin número vale 0, igual que "0", y quien llama no puede distinguirlos; "12abc" vale 12 sin que nadie se entere de la basura. Y un número fuera de rango es comportamiento indefinido: glibc convierte con strtol y recorta a int, y 4 294 967 297, que es 2³² + 1, quedó en 1. Un programa que reciba ese texto como PID actuaría sobre init. La calculadora del tema 20 usaba atol: ./calc suma 3 x habría sumado 3 + 0 sin quejarse.

La última línea muestra una sorpresa de la familia sin signo: strtoul acepta el signo menos y niega el resultado en aritmética sin signo, de modo que "-1" se convierte en el valor máximo, sin error. Para leer valores que no pueden ser negativos, hay que rechazar el - antes de convertir.

12. Validar un argumento de punta a punta

Con snprintf y strtol podemos corregir cmdline.c, que en el tema 21 concatenaba su argumento con strcat sin comprobar nada. La nueva versión convierte el argumento en un número, verificando todo lo que vimos en la sección 10, y arma la ruta con snprintf a partir del número ya validado:

pidinfo.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>
#include <fcntl.h>
#include <unistd.h>

/* convierte texto en un long entre min y max; 0 si lo logró, -1 si no */
static int convertir_long(const char *texto, long min, long max, long *resultado)
{
    char *fin;

    errno = 0;
    long valor = strtol(texto, &fin, 10);
    if (fin == texto)                     /* no había ningún dígito */
        return -1;
    if (*fin != '\0')                     /* sobra texto después del número */
        return -1;
    if (errno == ERANGE || valor < min || valor > max)
        return -1;
    *resultado = valor;
    return 0;
}

int main(int argc, char *argv[])
{
    long pid;
    char ruta[64];
    char buf[4096];

    if (argc != 2 || convertir_long(argv[1], 1, INT_MAX, &pid) != 0) {
        fprintf(stderr, "uso: %s PID   (un entero positivo)\n", argv[0]);
        return 1;
    }
    int n = snprintf(ruta, sizeof ruta, "/proc/%ld/cmdline", pid);
    if (n < 0 || (size_t) n >= sizeof ruta) {
        fprintf(stderr, "%s: ruta demasiado larga\n", argv[0]);
        return 1;
    }

    int fd = open(ruta, O_RDONLY);
    if (fd < 0) {
        perror(ruta);
        return 1;
    }
    ssize_t leidos = read(fd, buf, sizeof buf - 1);
    close(fd);
    if (leidos < 0) {
        perror("read");
        return 1;
    }
    buf[leidos] = '\0';

    printf("PID %ld:", pid);
    for (const char *p = buf; p < buf + leidos; p += strlen(p) + 1)
        printf(" [%s]", p);
    printf("\n");
    return 0;
}
terminal
$ ./pidinfo 1
PID 1: [/sbin/init]
$ ./pidinfo 999999
/proc/999999/cmdline: No such file or directory
$ for a in abc 12abc -5 99999999999 '' '1/../self'; do ./pidinfo "$a"; done
uso: ./pidinfo PID   (un entero positivo)
uso: ./pidinfo PID   (un entero positivo)
uso: ./pidinfo PID   (un entero positivo)
uso: ./pidinfo PID   (un entero positivo)
uso: ./pidinfo PID   (un entero positivo)
uso: ./pidinfo PID   (un entero positivo)

Las seis entradas inválidas se rechazan, cada una por una regla distinta de convertir_long: sin dígitos, texto sobrante, fuera del rango pedido, desbordamiento del long y texto vacío. La última es la más interesante. La versión del tema 21 habría armado /proc/1/../self/cmdline, una ruta válida que muestra otro proceso: al pegar texto externo en una ruta, quien lo escribe puede navegar el sistema de archivos con ... Al convertir primero a número y formatear después con %ld, la ruta solo puede contener dígitos en esa posición. Es un principio general: validar los datos en la forma más restringida posible, un número, una lista de valores permitidos, antes de usarlos para construir rutas u órdenes.

El PID 999999 es un número válido de un proceso que no existe; ese error ya no es de la entrada sino del sistema, y lo informa open con la convención del tema 11: devuelve −1 y deja la causa en errno, que perror traduce a un mensaje.

13. Leer /etc/passwd sin confiar en él

El último programa reúne todo el bloque de cadenas: lee un archivo línea por línea con fgets, detecta las líneas demasiado largas, las separa en campos con strsep, convierte el UID con strtoul rechazando signos y basura, y muestra el administrador y las cuentas de personas, que en Linux tienen UID 0 y desde 1000 en adelante:

usuarios.c
#define _DEFAULT_SOURCE                   /* strsep */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

enum { NOMBRE, CLAVE, UID, GID, GECOS, DIRECTORIO, INTERPRETE, NCAMPOS };

/* separa linea en NCAMPOS campos; devuelve 0 si tenía exactamente esa cantidad */
static int separar(char *linea, char *campos[NCAMPOS])
{
    char *resto = linea;
    int i = 0;

    for (char *c; (c = strsep(&resto, ":")) != NULL; i++) {
        if (i == NCAMPOS)
            return -1;                    /* sobran campos */
        campos[i] = c;
    }
    return i == NCAMPOS ? 0 : -1;
}

static int convertir_uid(const char *texto, unsigned long *uid)
{
    char *fin;

    if (texto[0] < '0' || texto[0] > '9')     /* ni signo ni blancos */
        return -1;
    errno = 0;
    *uid = strtoul(texto, &fin, 10);
    return (fin == texto || *fin != '\0' || errno == ERANGE) ? -1 : 0;
}

int main(int argc, char *argv[])
{
    const char *archivo = argc > 1 ? argv[1] : "/etc/passwd";
    FILE *f = fopen(archivo, "r");
    char linea[1024];
    int numero = 0;

    if (f == NULL) {
        perror(archivo);
        return 1;
    }
    while (fgets(linea, sizeof linea, f) != NULL) {
        numero++;
        size_t largo = strcspn(linea, "\n");
        if (linea[largo] != '\n' && !feof(f)) {
            fprintf(stderr, "%s:%d: línea demasiado larga\n", archivo, numero);
            int c;
            while ((c = getc(f)) != '\n' && c != EOF)
                ;                         /* descartar el resto */
            continue;
        }
        linea[largo] = '\0';

        char *campos[NCAMPOS];
        unsigned long uid;
        if (separar(linea, campos) != 0 || convertir_uid(campos[UID], &uid) != 0) {
            fprintf(stderr, "%s:%d: línea mal formada\n", archivo, numero);
            continue;
        }
        if (uid == 0 || uid >= 1000)
            printf("%-10s uid %-6lu %-20s %s\n", campos[NOMBRE], uid,
                   campos[DIRECTORIO], campos[INTERPRETE]);
    }
    fclose(f);
    return 0;
}
terminal · con el archivo del sistema, y con uno armado con errores
$ ./usuarios
root       uid 0      /root                /bin/bash
nobody     uid 65534  /nonexistent         /usr/sbin/nologin
ana        uid 1000   /home/ana            /bin/bash
$ printf 'root:x:0:0:root:/root:/bin/bash\nmal:x:abc:1::/:/bin/sh\ncorta:x:5\nneg:x:-1:0::/:/bin/sh\n' > p.txt
$ ./usuarios p.txt
root       uid 0      /root                /bin/bash
p.txt:2: línea mal formada
p.txt:3: línea mal formada
p.txt:4: línea mal formada

Las líneas inválidas no detienen el programa: se informan en stderr, con el nombre del archivo y el número de línea, el formato que usan gcc y casi todas las herramientas de Unix, y el resto del archivo se sigue procesando. Con 2>/dev/null quedaría solo la lista. Cada defensa del programa responde a una pregunta del tema:

Los campos que muestra el programa son punteros dentro de linea: no se copió ningún texto, y por eso todo vale hasta que fgets lea la línea siguiente sobre el mismo arreglo. Un programa que quisiera guardar los nombres tendría que copiarlos a memoria propia, con strdup, en el tema 28. Y para consultar usuarios de verdad, un programa debería usar getpwnam y getpwent, de pwd.h, que además consultan otras fuentes, como directorios de red; leer el archivo a mano es aquí un ejercicio de análisis de texto.

14. Errores frecuentes

ErrorQué pasaCorrección
Ignorar el valor de retorno de snprintfUn texto recortado se usa como si estuviera completo: otra ruta, otra orden.if (n < 0 || (size_t) n >= tam) es un error.
pos += snprintf(…) sin comprobarpos supera el tamaño y tam - pos da la vuelta: desbordamiento.Comprobar cada llamada antes de sumar.
strncpy(dst, src, sizeof dst)Sin terminador si src no cabe.snprintf(dst, sizeof dst, "%s", src).
strncat(dst, src, sizeof dst)El límite es lo que se agrega, no el tamaño: desborda.snprintf o strlcat.
strtok anidada o en una función llamada desde un bucle con strtokUn estado pisa al otro: se pierden campos sin aviso.strtok_r o strsep.
strtok con campos vacíosLos saltea: los campos se corren de lugar.strsep.
strtok sobre una literalEscribe en memoria de solo lectura: Segmentation fault.Copiar a un arreglo, o recorrer con strchr.
No mirar fin después de strtol"42abc" se acepta como 42, "abc" como 0.fin != texto && *fin == '\0'.
No poner errno = 0 antes de strtolUn ERANGE viejo se confunde con uno nuevo, o se pierde.errno = 0; justo antes de la llamada.
Base 0 con datos de personas"010" se lee como 8.Base 10.
atoi o atolSin errores: basura y desbordamiento pasan inadvertidos.strtol con todas las comprobaciones.
strtoul con "-1"Lo acepta como el máximo sin signo.Rechazar el - antes de convertir.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué devuelve snprintf(buf, 8, "%s", "sistema operativo"), y qué queda en buf?

Devuelve 17, el largo de «sistema operativo», que es lo que habría escrito con lugar suficiente. En buf quedan los 7 primeros caracteres, "sistema", y el terminador. Como 17 es mayor o igual que 8, hubo recorte.

2. ¿Por qué la comprobación del recorte es n >= tam y no n > tam?

Porque n no cuenta el terminador. Si n es igual a tam, el texto completo necesitaba tam + 1 bytes, y snprintf tuvo que sacrificar el último carácter para escribir el \0.

3. Si snprintf nunca desborda, ¿qué tiene de malo ignorar el recorte?

Que el texto recortado puede ser otro texto válido. En temporal.c, recortar la ruta eliminó el sufijo .tmp, y el programa habría borrado un archivo que no era temporal. Con rutas, nombres u órdenes, un texto incompleto es un texto equivocado, y hay que tratarlo como error.

4. ¿Cómo llegó lista_mal a escribir fuera de buf si solo usa snprintf?

Porque sumó a pos el valor devuelto por snprintf, que en el recorte es lo que necesitaba y no lo que escribió. pos quedó en 27, más allá del final de un arreglo de 24, y en la llamada siguiente tam - pos, en size_t, dio un número enorme. snprintf recibió un destino fuera del arreglo y un tamaño casi ilimitado.

5. ¿Por qué copias.c mostró «maximiliana»?

Porque strncpy(corto, "maximiliano", 8) copió 8 caracteres y no escribió terminador. Al mostrar corto como cadena, printf siguió leyendo después del arreglo, y lo que había a continuación en la pila era amplio, con "ana" y su \0.

6. ¿Qué significa el tercer argumento de strncat, y cuál es el valor correcto para un destino char d[16]?

Es la cantidad máxima de caracteres que se agregan, sin contar el terminador, que strncat siempre escribe. Para no desbordar d, tiene que ser sizeof d - strlen(d) - 1: el tamaño, menos lo que ya ocupa la cadena, menos el lugar del \0.

7. ¿Por qué strtok devolvió 6 campos de una línea de /etc/passwd que tiene 7?

Porque trata los delimitadores seguidos como uno solo: el campo vacío entre :: desapareció, y los campos siguientes se corrieron un lugar. strsep devuelve una cadena vacía en ese caso, y conserva las posiciones.

8. ¿Por qué la versión de anidado.c con strtok procesó solo la primera opción?

Porque strtok guarda en una única variable oculta dónde continuar. La llamada interna strtok(par, "=") reemplazó el estado del bucle externo, y cuando el bucle pidió el siguiente campo con strtok(NULL, ","), continuó desde el final de "rw" y devolvió NULL. strtok_r evita el problema con una variable de estado para cada separación.

9. ¿Qué cuatro comprobaciones hacen falta después de strtol para aceptar un texto como número?

Que fin != texto, es decir, que hubo al menos un dígito; que *fin == '\0', que no sobra texto; que errno no sea ERANGE, habiéndolo puesto en 0 antes de la llamada; y que el valor esté en el rango que el programa necesita, como de 1 a INT_MAX para un PID.

10. ¿Cuánto vale strtol("010", &fin, 0), y por qué?

Vale 8. Con base 0, strtol deduce la base del texto con las reglas de las constantes de C: un 0 inicial indica octal, y 10 en octal es 8. Con base 10, el resultado sería 10. Para datos escritos por personas se usa siempre base 10.

11. ¿Qué problemas tiene atoi?

No informa errores: un texto sin número da 0, igual que "0", y un texto con basura al final se acepta en silencio. Con un número fuera del rango de int, el comportamiento es indefinido; en glibc, atoi("4294967297") dio 1. strtol permite detectar todos estos casos.

12. ¿Por qué pidinfo.c convierte el argumento a número antes de armar la ruta, en lugar de pegarlo directamente?

Porque pegar texto externo en una ruta permite que quien lo escribe la manipule, por ejemplo con .. para llegar a otro directorio, como 1/../self. Convertido a número y formateado con %ld, en esa posición de la ruta solo puede haber dígitos. Además, el número validado no puede ser tan largo como para recortar la ruta.

13. En usuarios.c, ¿cómo se detecta una línea más larga que el buffer?

fgets guarda como máximo sizeof linea - 1 caracteres. Si lo leído no termina en \n y el archivo no llegó a su fin, la línea continúa: el programa la informa como error y lee con getc hasta el \n para descartar el resto, en lugar de tomar el pedazo como una línea completa.

Para practicar

  1. Escribe int copiar(char *dst, size_t tam, const char *src) que use snprintf y devuelva 0 si la copia fue completa y −1 si hubo recorte. Pruébala con un destino de tamaño 1 y con uno de tamaño 0.
  2. Corrige saludo.c y desborde.c del tema 21 con snprintf, de modo que informen un error cuando el nombre no cabe, y verifica con AddressSanitizer y con -O2 que ya no hay desbordamientos.
  3. Agrega a lista.c una función que, en lugar de fallar, deje la lista recortada en el último PID completo y agregue … al final, sin pasarse del tamaño.
  4. Escribe int convertir_int(const char *texto, int *resultado) con strtol, y úsala para reemplazar los atol de la calculadora calc.c del tema 20, de modo que ./calc suma 3 x informe un error.
  5. Escribe un programa que reciba permisos en octal, como 755 o 0640, los convierta con strtol en base 8, rechace valores mayores que 0777, y los muestre en la forma rwxr-xr-x.
  6. Modifica usuarios.c para que reciba un nombre de intérprete como argumento, por ejemplo /bin/bash, y muestre solo las cuentas que lo usan. Prueba qué pasa con una línea de 2000 caracteres.
  7. Separa la salida de mount, o el contenido de /proc/self/mounts, en campos con strtok_r por espacios, y las opciones de montaje del cuarto campo por comas, con un segundo strtok_r anidado.