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… | Funciones | Consecuencia |
|---|---|---|
| Escribe igual, fuera del buffer | strcpy, strcat, sprintf, gets | Desbordamiento: comportamiento indefinido. Solo con datos cuyo largo está garantizado. |
| Recorta y deja una cadena válida, e informa | snprintf, strlcpy, strlcat, fgets | Seguro, si el programa mira lo que informan. |
| Recorta y puede dejar algo que no es una cadena | strncpy | Un 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.
#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;
}
$ 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:
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:
#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;
}
$ ./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:
#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;
}
$ ./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.
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:
- Copia como máximo
nbytes. - Si el origen es más corto que
n, rellena con ceros todo el resto del destino. - Si el origen tiene
ncaracteres o más, no escribe el terminador.
#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;
}
$ ./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:
#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;
}
$ 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:
#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;
}
$ ./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ón | El límite es | ¿Termina siempre? | ¿Avisa del recorte? | Uso |
|---|---|---|---|---|
strcpy, strcat | No hay | Sí | No: desborda | Solo si el largo está garantizado. |
strncpy | Bytes a escribir | No | No | Campos de largo fijo, no cadenas. |
strncat | Caracteres a agregar | Sí | No | Mejor evitarla. |
strlcpy, strlcat | Tamaño del destino | Sí | Sí, con el retorno | Copias simples; glibc 2.38 o posterior. |
snprintf | Tamaño del destino | Sí | Sí, con el retorno | Todo lo demás; disponible en todas partes. |
memcpy | Bytes exactos | Solo 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.
#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;
}
$ ./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:
- Modifica la cadena. Reemplaza cada delimitador encontrado por un
\0, y los campos que devuelve son punteros a pedazos de la cadena original. Por eso no se puede usar sobre una literal, que es de solo lectura, ni sobre un texto que se necesite intacto después;campos.ctrabaja sobre arreglos propios, y hace una copia para la segunda pasada. - Guarda su estado en una variable oculta. Para saber dónde continuar cuando recibe
NULL, guarda la posición en una variable estática de la biblioteca, una sola para todo el programa. - Los delimitadores son un conjunto de caracteres, no una cadena:
strtok(s, " \t")separa por espacios o tabuladores.
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 =:
#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;
}
$ ./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ón | Estado | Delimitadores seguidos | Origen | Usar para |
|---|---|---|---|---|
strtok | Oculto, uno solo | Se unen | C estándar | Nada nuevo: strtok_r la reemplaza. |
strtok_r | En una variable de quien llama | Se unen | POSIX | Palabras separadas por blancos. |
strsep | En una variable de quien llama | Campos vacíos | BSD, glibc | Registros 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:
#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;
}
$ ./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:
- Acepta blancos iniciales y un signo.
" -17"se leyó entero. Si eso no es aceptable, como en un UID, hay que comprobar el primer carácter antes de llamar. - Se detiene en el primer carácter que no entiende, sin protestar.
"42abc"da 42 y dejafinapuntando a"abc". Para exigir que el texto sea solo un número, hay que comprobar que*finsea'\0'. - Sin dígitos, devuelve 0, y
finqueda igual atexto. Es la única forma de distinguir"abc"o""de un"0"legítimo. - La base importa. Con base 10,
"010"es diez. Con base 0,strtolsigue las reglas de C: un0inicial significa octal, y"010"es ocho;0xsignifica hexadecimal. Para datos de personas se usa base 10; la base 8 es la de los permisos, como755, que vale 493. - Si el número no cabe, satura y avisa. Devuelve
LONG_MAXoLONG_MINy poneerrnoenERANGE. Como unLONG_MAXlegítimo también es posible, la única señal confiable eserrno, y por eso hay que ponerlo en 0 antes de la llamada:strtolno lo borra si todo sale bien.
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:
$ ./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:
#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;
}
$ ./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:
#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;
}
$ ./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:
- ¿Y si la línea no entra en el buffer?
fgetsno desborda, pero entrega un pedazo. Si el pedazo no termina en\ny el archivo no terminó, la línea era más larga: se informa y se descarta el resto, en lugar de procesar la mitad como si fuera una línea completa. - ¿Y si faltan o sobran campos?
separarusastrsep, que respeta los vacíos, y exige exactamente siete. Constrtok, el campoGECOSvacío de la cuentaanahabría corrido todo un lugar. - ¿Y si el UID no es un número?
convertir_uidrechazaabc, y también-1, questrtoulhabría convertido en un UID enorme.
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
| Error | Qué pasa | Corrección |
|---|---|---|
Ignorar el valor de retorno de snprintf | Un 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 comprobar | pos 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 strtok | Un estado pisa al otro: se pierden campos sin aviso. | strtok_r o strsep. |
strtok con campos vacíos | Los saltea: los campos se corren de lugar. | strsep. |
strtok sobre una literal | Escribe 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 strtol | Un 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 atol | Sin 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
- Toda función que escribe en un buffer debe recibir su tamaño, y el programa debe decidir qué hacer cuando el texto no cabe.
snprintfnunca escribe más detambytes, siempre termina la cadena y devuelve el largo que habría necesitado: si es>= tam, hubo recorte. Con tamaño 0, solo mide.- Un texto recortado es un texto distinto. Con rutas, nombres u órdenes, el recorte se trata como un error.
- Al armar una cadena por partes, cada valor devuelto se comprueba antes de sumarlo; si no,
tam - posda la vuelta ensize_tysnprintfdesborda. strncpyno termina la cadena si el origen no cabe, y rellena con ceros si sobra lugar: es para campos de largo fijo, no para cadenas.- El límite de
strncates lo que se agrega, no el tamaño del destino. strlcpyystrlcat, en glibc desde la 2.38, reciben el tamaño del destino y avisan del recorte comosnprintf.strtokmodifica la cadena, guarda su estado en una variable oculta y saltea los campos vacíos.strtok_rguarda el estado en una variable de quien llama;strsep, además, devuelve los campos vacíos.strtolinforma dónde dejó de leer y si hubo desbordamiento: se poneerrnoen 0 antes, y después se compruebafin != texto,*fin == '\0',ERANGEy el rango pedido.atoino informa errores y tiene comportamiento indefinido fuera de rango;strtoulacepta negativos.- Los datos externos se validan en la forma más restringida posible, como un número, antes de usarlos para construir rutas u órdenes.
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
- Escribe
int copiar(char *dst, size_t tam, const char *src)que usesnprintfy 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. - Corrige
saludo.cydesborde.cdel tema 21 consnprintf, de modo que informen un error cuando el nombre no cabe, y verifica con AddressSanitizer y con-O2que ya no hay desbordamientos. - Agrega a
lista.cuna 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. - Escribe
int convertir_int(const char *texto, int *resultado)constrtol, y úsala para reemplazar losatolde la calculadoracalc.cdel tema 20, de modo que./calc suma 3 xinforme un error. - Escribe un programa que reciba permisos en octal, como
755o0640, los convierta constrtolen base 8, rechace valores mayores que0777, y los muestre en la formarwxr-xr-x. - Modifica
usuarios.cpara 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. - Separa la salida de
mount, o el contenido de/proc/self/mounts, en campos constrtok_rpor espacios, y las opciones de montaje del cuarto campo por comas, con un segundostrtok_ranidado.