Manipular texto sin herirte
strncpy frente a snprintf, el zoo de alternativas seguras y sus trampas, y cómo escribir tus propias utilidades de texto con longitud explícita y truncado detectable.
La respuesta popular a los desbordamientos fue añadir una n al nombre de las funciones peligrosas. Fue una respuesta incompleta y en algunos casos contraproducente: strncpy no es strcpy con límite, strncat no cuenta lo que crees que cuenta, y el Anexo K que iba a resolverlo todo apenas se implementó fuera de Windows. La salida no está en memorizar cuál de las variantes es la buena, sino en adoptar una disciplina: toda operación sobre texto recibe la capacidad del destino, garantiza el terminador y te dice si truncó. Este capítulo construye esa disciplina y las herramientas para sostenerla.
- Conocer la semántica real de
strncpyystrncat, con sus dos trampas clásicas. - Usar
snprintfcomo herramienta por defecto y leer bien su valor de retorno. - Situar
strlcpy,memccpyy el Anexo K en el mapa de alternativas portables. - Escribir utilidades propias con capacidad explícita y truncado detectable.
strncpy no es strcpy con límite
strncpy se diseñó en los setenta para rellenar campos de longitud fija en entradas de directorio de UNIX, no para copiar cadenas con seguridad. Su comportamiento delata ese origen:
char d[8];
strncpy(d, "hola", sizeof d);
/* Trampa 1: RELLENA el resto con ceros -> escribe los 8 bytes siempre */
strncpy(d, "una cadena larga", sizeof d);
/* Trampa 2: NO termina el destino -> d queda sin '\0' */
Las dos consecuencias son graves en direcciones opuestas. La primera es un coste oculto: copiar un nombre de cuatro letras a un buffer de cuatro kilobytes escribe cuatro kilobytes. La segunda es un fallo de corrección: si el origen tiene al menos tantos caracteres como la capacidad, el destino no queda terminado y la siguiente lectura se sale del buffer.
strncat es peor de entender, porque su n no es la capacidad del destino sino el número máximo de caracteres a añadir, y además escribe siempre un terminador adicional. La expresión correcta es incómoda y por eso casi nadie la escribe bien:
strncat(d, origen, sizeof d - strlen(d) - 1);
/* si strlen(d) ya vale sizeof d, la resta se desborda como size_t */
La regla mnemotécnica es dura pero exacta: strncpy acota la escritura, no garantiza la cadena. Si vas a usarla, el terminador es responsabilidad tuya y debe ir en la línea siguiente: d[sizeof d - 1] = '\0';. Y si lo escribes así, ya has admitido que la función no hace su trabajo sola. Los compiladores modernos lo saben: GCC avisa con -Wstringop-truncation precisamente en este patrón.
snprintf: la herramienta por defecto
snprintf resuelve de una vez los tres problemas: acota la escritura a la capacidad, siempre termina el destino y devuelve información suficiente para detectar el truncado.
char ruta[64];
int r = snprintf(ruta, sizeof ruta, "%s/%s.log", dir, nombre);
if (r < 0) { /* error de codificacion */ }
else if ((size_t)r >= sizeof ruta) { /* TRUNCADO: r es lo que HABRIA escrito */ }
La clave está en el valor de retorno: no es lo que escribió, es lo que habría escrito si hubiera cabido, sin contar el terminador. Comparar ese número con la capacidad es la comprobación de truncado, y es la razón por la que snprintf domina sobre todas las variantes de strcpy.
Ese mismo contrato da el idioma de dos pasadas para dimensionar un buffer exacto:
int n = snprintf(nullptr, 0, "%s=%d", clave, valor); /* solo mide */
char *s = malloc((size_t)n + 1);
if (s) snprintf(s, (size_t)n + 1, "%s=%d", clave, valor);
Para componer una cadena por partes, no llames a strcat en bucle: mantén un desplazamiento y avánzalo con el retorno de snprintf, comprobando el truncado en cada paso. Pasas de coste cuadrático a lineal y de un desbordamiento potencial por trozo a una única comprobación uniforme. Es el patrón que usan los generadores de mensajes de log y de consultas en código de sistemas serio.
El zoo de alternativas
strlcpy y strlcat
Nacidas en OpenBSD, siempre terminan y devuelven la longitud que habría tenido el resultado, así que el truncado se detecta comparando con la capacidad. Llegaron a glibc en 2023 y a POSIX en su edición de 2024, pero siguen sin estar en el estándar C.
memccpy
Está en C23 y en POSIX desde siempre: copia hasta un byte centinela o hasta agotar el límite y devuelve un puntero al byte siguiente al centinela, o nulo si no lo encontró. Con '\0' como centinela es una copia acotada con detección de truncado y sin relleno.
Anexo K: strcpy_s y compañía
Las funciones con sufijo _s son un anexo opcional de C11 y C23. Microsoft las implementó; el resto del ecosistema no. El informe TR 24731-4 documentó su escasa adopción y sus problemas de diseño. No las uses si buscas portabilidad.
memcpy con longitud calculada
Cuando ya conoces la longitud, memcpy más terminador explícito es la operación más rápida y la más fácil de auditar: no busca ceros, no rellena y hace exactamente lo que dice.
De todas ellas, memccpy es la que más gente desconoce y la que mejor encaja con la disciplina de este capítulo, porque el propio valor de retorno distingue los dos desenlaces sin cálculos adicionales:
/* Copia acotada con deteccion de truncado, todo en C23 estandar */
char *fin = memccpy(dst, src, '\0', cap);
if (fin == nullptr) {
dst[cap - 1] = '\0'; /* no cupo: truncamos de forma explicita */
/* ...tratar el truncado... */
}
Compáralo con strncpy: misma cota, ningún relleno, y una respuesta clara a la única pregunta que importa. La diferencia entre una API segura y una insegura casi nunca está en lo que hace, sino en lo que te permite saber.
Tus propias utilidades
La solución de fondo es dejar de tratar el texto como “puntero a char” y empezar a tratarlo como “puntero más capacidad”, con el truncado como resultado explícito y no como accidente silencioso.
/* Copia acotada: termina siempre y avisa si trunco */
bool copia_str(char *dst, size_t cap, const char *src) {
if (cap == 0) return false;
size_t n = strlen(src);
size_t k = (n < cap - 1) ? n : cap - 1;
memcpy(dst, src, k);
dst[k] = '\0';
return n < cap; /* false si hubo truncado */
}
/* Escritor con cursor: componer sin recorrer lo ya escrito */
struct escritor { char *buf; size_t cap, pos; bool desbordado; };
void esc_add(struct escritor *e, const char *txt) {
if (e->desbordado || e->pos >= e->cap) { e->desbordado = true; return; }
int r = snprintf(e->buf + e->pos, e->cap - e->pos, "%s", txt);
if (r < 0 || (size_t)r >= e->cap - e->pos) { e->desbordado = true; return; }
e->pos += (size_t)r;
}
flowchart TD A[Necesito escribir texto en un buffer] --> B[Conozco la capacidad del destino] B --> C[Formateo con partes variables] B --> D[Copia simple de una cadena] C --> E[snprintf y comprobar el retorno] D --> F[memcpy con longitud y terminador explicito] E --> G[Truncado detectado y tratado] F --> G G --> H[Invariante: el destino siempre termina] style H fill:#a6e3a1,color:#11111b
La lección final de todo el nivel no es cuál función usar, sino qué exigirle a cualquiera que uses. Compara los tres contratos que has visto: strcpy escribe hasta donde haga falta y puede corromper memoria; strncpy no corrompe pero puede dejarte una cadena sin terminar, que es la misma corrupción diferida al siguiente lector; snprintf acota, termina y te lo cuenta. Esa tercera propiedad es la que separa una función segura de una función meramente acotada, y es la que casi todo el código ignora, porque el retorno de snprintf se descarta con la misma naturalidad con que se descarta el de printf. Piensa en lo que significa un truncado no detectado: una ruta de fichero que se queda a medias y apunta a otro directorio, un identificador de sesión recortado que colisiona con el de otro usuario, una consulta que pierde su cláusula final, un mensaje de log que oculta justo el dato que necesitabas en la investigación posterior. Ninguno de esos casos produce un fallo de segmentación, ninguno enciende un sanitizer, ninguno aparece en las pruebas: producen datos plausibles, bien formados y equivocados, que es la categoría de error más cara de encontrar y la más peligrosa en producción. Por eso la disciplina que debes interiorizar es más profunda que una lista de funciones prohibidas. Toda operación que escribe en un buffer ajeno tiene tres obligaciones —respetar la capacidad, dejar la invariante en pie y reportar si no cupo— y cuando la biblioteca no ofrece las tres, tu trabajo es envolverla en una función propia que sí las ofrezca. Escribir esas veinte líneas una vez, con nombres claros y retorno booleano, te compra más seguridad que cualquier recomendación de estilo, porque convierte una regla que hay que recordar en una API que cuesta usar mal.
- Copia una cadena de veinte caracteres a un buffer de ocho con
strncpye imprime los ocho bytes en hexadecimal. Comprueba que no hay terminador y arréglalo. - Repite el ejercicio con un origen corto y un buffer de 4096. Mide cuántos bytes escribe realmente
strncpyy explica el coste. - Escribe la expresión correcta del tercer argumento de
strncaty demuestra con un caso límite que la resta puede desbordarse comosize_t. - Construye una ruta con
snprintfy trata explícitamente los tres desenlaces: éxito, truncado y error negativo. - Implementa
copia_stryesc_addde la sección anterior, cúbrelas con pruebas de capacidad cero, capacidad justa y truncado, y ejecútalas bajo AddressSanitizer y UndefinedBehaviorSanitizer.