¿Por qué el mismo archivo tiene un hash diferente?
Calculas el hash de un archivo, alguien calcula el de una copia que da por idéntica, y los resúmenes no coinciden. Un hash es una de las cosas más deterministas de la computación, así que el valor solo cambió porque los bytes cambiaron: en silencio, de forma invisible. Esta guía recorre los sospechosos habituales —codificación, BOM, finales de línea, modo texto frente a binario, datos estructurados y comprimidos reempaquetados— y termina mostrando cómo normalizar para que los hashes vuelvan a coincidir.
Calculas el hash de un archivo. Un colega calcula el de una copia que da por idéntica. Los dos resúmenes no coinciden. O calculas el hash hoy, lo vuelves a calcular tras un viaje de ida y vuelta por un servidor de compilación, y el valor ha cambiado. No editaste nada. Así que la primera sospecha es la que asusta: ¿el hash falla?
No falla. Un hash es una de las cosas más fiables de la computación. El valor cambió porque los bytes cambiaron, en silencio, de una forma que no viste. Este artículo trata de todas las maneras en que «el mismo archivo» deja de estar compuesto por los mismos bytes, y de cómo volver a alinearlos.
La idea central: un hash es determinista
Un hash criptográfico es una función pura de los bytes exactos que le das. Los mismos bytes entran, el mismo digest sale: en cada máquina, en cada lenguaje, en cada ejecución, para siempre. No hay azar, ni reloj, ni semilla propia de la máquina. El SHA-256 de una secuencia de bytes dada es una constante del universo.
Todo lo que sigue asume que ambos lados ejecutaron el mismo algoritmo y que comparas los digests en sí: SHA-256 contra SHA-256, no SHA-256 contra MD5, ni hexadecimal contra base64. Bajo esa premisa, la consecuencia es tajante. Si dos hashes difieren, las dos secuencias de bytes difieren. Y punto. No existe «el mismo archivo con un hash distinto». Lo que tienes son dos secuencias de bytes distintas que crees que son el mismo archivo. La que miente es la palabra «mismo», no el hash.
Así que la pregunta nunca es «por qué cambió el hash». Es «qué cambió los bytes mientras no mirabas». El resto de esta guía es un recorrido por los sospechosos habituales, y casi todos son invisibles cuando abres el archivo en un editor.
Vale la pena detenerse en esa segunda parte, porque es donde más gente tropieza. Asegúrate de que lo que difiere es el digest, no solo su forma impresa. AB… y ab… son un mismo valor en mayúsculas y minúsculas, y un mismo digest se puede mostrar en hexadecimal o en base64. Si ese es tu caso, los bytes son idénticos y no pasa nada: esa trampa y sus parientes viven en por qué no coincide mi checksum. De aquí en adelante, damos por hecho el mismo algoritmo y una diferencia real en el digest.
Míralo pasar en treinta segundos
Antes de la taxonomía, observa el efecto directamente. Dos líneas de texto, idénticas en pantalla, primero con finales de línea de Unix y luego de Windows:
printf 'a\nb\n' | sha256sum # 4 bytes
# 911169ddaaf146aff539f58c26c489af3b892dff0fe283c1c264c65ae5aa59a2
printf 'a\r\nb\r\n' | sha256sum # 6 bytes
# 58055bdcc73787eb88c78d36f0b4939e9c5dc1c3ad17e25cc85a6833cf1a0cab
Dos bytes de más que no puedes ver, y el digest queda irreconocible. Lo mismo ocurre con una marca de orden de bytes delante de la palabra hello:
printf 'hello' | sha256sum
# 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
printf '\xef\xbb\xbfhello' | sha256sum
# 7489ebbcc2a00056ddaaaac190bce473e5c03696ea1bd8ed83cf59a174283862
Ejecútalos tú mismo. Una vez que has visto tres bytes invisibles reescribir un digest, el resto de este artículo deja de sorprender.
Primero: ¿estás hasheando los bytes que crees?
Antes de culpar a nada sutil, descarta la versión burda: hasheaste algo distinto del archivo que querías.
- Archivo equivocado, nombre correcto. Otra compilación, otra versión, una copia en otra carpeta. Los nombres mienten; comprueba la ruta completa y el tamaño.
- El archivo aún se estaba escribiendo. Lo hasheaste a mitad de una descarga o una exportación, y un segundo después una herramienta volcó más bytes.
- Un enlace simbólico o un acceso directo, no el destino. Hasheaste el enlace en sí, o un enlace que en la otra máquina resuelve a otro archivo.
- Un directorio, no un archivo. El «hash de una carpeta» depende por completo de la herramienta: qué archivos incluye, en qué orden y si mezcla metadatos. Dos herramientas dan dos respuestas para el «mismo» directorio.
Confirma primero que apuntas al mismo objeto exacto. Muchas veces el tamaño lo resuelve: tamaños distintos significan bytes distintos, y puedes dejar de buscar algo más complicado.
Texto: «el mismo texto» son muchas secuencias de bytes
La mayoría de estos casos se origina aquí. Abres dos archivos en un editor y se ven idénticos, carácter por carácter. Pero el hash lee bytes, no caracteres, y los mismos caracteres se pueden guardar como bytes muy distintos.
Codificación de caracteres. La letra A ocupa un byte en UTF-8 y dos en UTF-16. El texto acentuado y no latino diverge mucho más. Un archivo guardado de nuevo de UTF-8 a UTF-16, o a una página de códigos heredada como Windows-1252 o Shift-JIS, es otra secuencia de bytes con el mismo texto visible. El comando file suele nombrar la codificación de cada lado.
Una marca de orden de bytes (BOM). Algunos editores y exportaciones —el Bloc de notas clásico de Windows, la exportación CSV de Excel— anteponen al archivo un BOM UTF-8 de tres bytes (EF BB BF). En pantalla es invisible. Cambia todos los hashes. Un lado lo tiene, el otro no, y los resúmenes nunca coinciden.
Finales de línea. Una de las causas más comunes entre plataformas. Unix termina una línea con LF (\n); Windows usa CRLF (\r\n). En Windows cada línea lleva un byte de más, así que un archivo de 100 líneas difiere en 100 bytes sin un solo cambio visible. Git lo vuelve rutina: con core.autocrlf activado, reescribe los finales de línea al hacer checkout y commit, así que el archivo del árbol de trabajo difiere de verdad entre un clon de Windows y uno de Linux.
Un salto de línea final. Editores y herramientas no se ponen de acuerdo sobre si un archivo termina en un salto de línea final. echo "x" añade uno; printf x no:
printf x | sha256sum # un byte
printf 'x\n' | sha256sum # dos bytes — un digest completamente distinto
Un \n de más al final es un byte de más, y con un hash criptográfico como SHA-256 el efecto avalancha vuelve irreconocible todo el digest.
Modo texto frente a modo binario. Este es más sutil, y fácil de pasar por alto porque el archivo en disco nunca cambia. Algunas herramientas de hash pueden leer un archivo en «modo texto» y traducir los finales de línea mientras leen, así que los bytes que llegan al hash no son los que hay en disco. GNU md5sum y sha256sum exponen -b (binario) y -t (texto); en Unix ambos se comportan igual, pero en los ports de Windows el modo texto es real y normaliza CRLF en silencio. La misma trampa existe en las API de los lenguajes: abrir un archivo en modo texto en lugar de binario, y hashear lo que lees, entrega al hash un flujo traducido y recodificado. Al hashear, abre siempre en modo binario.
Caracteres invisibles y normalización. Espacios al final de una línea. Un tabulador cambiado por espacios. Un carácter de ancho cero o un espacio de no separación pegado desde una página web. Y uno sutil: la normalización Unicode. El carácter é puede ser un solo punto de código (U+00E9) o una e seguida de un acento combinante (U+0301). Se ven idénticos y son «el mismo» para una persona, pero son bytes distintos.
Conviene trazar aquí un límite: el hash de un archivo cubre solo su contenido, y el nombre no forma parte de él. Renombrar un archivo nunca cambia el hash de su contenido, y la normalización del nombre tampoco. La normalización solo influye cuando el nombre en sí es la entrada: al hashear una cadena de ruta, un listado de directorio, una lista de nombres o los metadatos de un comprimido. Vale la pena saberlo, porque algunos sistemas de archivos y herramientas de macOS normalizan los nombres mientras que Linux suele conservar los bytes originales, así que una ruta o una lista de nombres puede diferir entre sistemas aunque el contenido de cada archivo sea idéntico.
Datos estructurados: mismo significado, otros bytes
Los archivos de texto no son lo único que parece estable sin serlo. Cuando hasheas un documento JSON, estás hasheando una serialización concreta, no los datos. Cualquier cosa que vuelva a serializar —otra biblioteca, otro lenguaje, una vuelta por una base de datos— puede producir bytes distintos con un significado idéntico:
printf '{"a":1,"b":2}' | sha256sum # 43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777
printf '{"b":2,"a":1}' | sha256sum # 3fb75453225c732a76b7899ea2096dda1455189c89817239732182f73fe5a09f
printf '{"a": 1, "b": 2}' | sha256sum # d8497d9d82770a70729261095aa98f7ef5154d7af499f8037b6ca250296785a6
Tres digests, unos mismos datos. En el segundo solo cambió el orden de las claves; en el tercero, solo los espacios. Otros serializadores varían aún más: escapar lo no ASCII como \uXXXX o emitirlo tal cual, añadir o no un salto final, cómo imprimen los números, o si ordenan las claves.
El arreglo no es hashear con más fuerza. Es canonicalizar antes de hashear: acordar una serialización y producirla en ambos lados. Ordena las claves, quita los espacios sin significado, fija las reglas de números y escapes. JSON tiene un estándar justo para esto —JSON Canonicalization Scheme, RFC 8785— y casi todos los ecosistemas tienen una biblioteca. Hashea la forma canónica y dos sistemas que coincidan en los datos coincidirán en el digest.
Archivos comprimidos: mismo contenido, otro contenedor
Los archivos ZIP y los tarballs añaden su propia capa. Dos archivos comprimidos pueden contener archivos idénticos byte a byte y aun así hashear distinto, porque el contenedor registra más que los archivos:
- Marcas de tiempo. La mayoría de formatos guarda la hora de modificación de cada entrada. Reempaqueta un minuto después y cambian los bytes del comprimido.
- Orden de los archivos. El orden en que se añaden las entradas es parte del comprimido. Dos ejecuciones que recorren el directorio en distinto orden producen distintos bytes.
- Ajustes de compresión. Un nivel distinto, o una versión distinta de zlib/gzip, da distintos bytes comprimidos para la misma entrada.
- Metadatos. Permisos, IDs de propietario y grupo y —para
gzip— el nombre de archivo original y la hora de modificación grabados en la cabecera.
Esto atrapa a más gente de la que debería, porque muchos formatos que no consideras comprimidos son contenedores ZIP: .docx, .xlsx, .pptx, .jar, .epub. Abre un documento, no cambies nada, guárdalo otra vez, y el archivo hashea distinto: nuevas marcas de tiempo y quizá otro orden de entradas dentro del zip, aunque ninguna de sus partes haya cambiado.
Esta es una razón importante de que las compilaciones reproducibles sean difíciles —las versiones del toolchain, el entorno y las rutas de compilación también influyen— y explica que las herramientas ganaran opciones para quitar el ruido: gzip -n descarta el nombre y la hora, y con GNU tar puedes fijar orden y hora con tar --sort=name --mtime='UTC 1970-01-01' … (son opciones de GNU; el tar BSD que trae macOS no las admite todas). Si hasheas un comprimido para comparar compilaciones, hashea mejor el contenido extraído: abajo tienes la receta.
Una referencia rápida
| Se ve igual, pero los bytes difieren por… | Cómo detectarlo |
|---|---|
| Codificación (UTF-8 vs UTF-16, o una página heredada) | file archivo nombra la codificación |
| Un BOM UTF-8 en un lado | los primeros tres bytes son ef bb bf |
Finales LF vs CRLF | cat -A archivo muestra ^M al final de cada línea |
| Un salto de línea final en un lado | mira si el último byte es 0a |
| Espacios finales o de ancho cero | un volcado hexadecimal lo revela |
| El modo texto traduce los finales de línea | recalcula leyendo el mismo archivo en binario |
| JSON reserializado (orden de claves, espacios, escapes) | canonicaliza ambos lados y luego compara |
| Un comprimido reempaquetado (hora, orden, nivel) | los tamaños difieren; descomprime y compara los archivos |
Cómo encontrar de verdad la diferencia
Deja de rehashear y empieza a comparar bytes.
Los comandos siguientes asumen una shell de Linux o macOS, y algunos cambian según la plataforma. sha256sum es de GNU; en macOS se usa shasum -a 256. cat -A existe solo en la versión GNU: el cat BSD de macOS ofrece -e y -t. Y diff <(…) necesita la sustitución de procesos de bash o zsh. En Windows, fc /b compara dos archivos byte a byte y Format-Hex de PowerShell vuelca los bytes.
- Compara tamaños primero.
ls -l, owc -c. Fíjate en qué te dice y qué no una diferencia de tamaño: da el cambio neto de longitud, no el número de bytes modificados; cambia 1000 bytes por otros 1000 y el tamaño es idéntico. Aun así, la diferencia es una pista útil: unos pocos bytes sugieren un BOM o un salto final; una brecha que crece con el número de líneas apunta aCRLF. - Mira los bytes.
xxd archivo | head, o compara dos volcados condiff <(xxd a) <(xxd b). El primer desplazamiento que difiere acota dónde empieza la diferencia a nivel de bytes, que es un punto de partida y no automáticamente la causa raíz. Un cambio de codificación altera todo lo que viene después,CRLFreaparece en cada línea, y la primera diferencia de un comprimido suele ser solo una marca de tiempo. - Revela lo invisible.
cat -Amuestra finales de línea y espacios finales, y un vistazo a los primeros tres bytes atrapa un BOM.fileadivinará la codificación, aunque para UTF-8 sin BOM y algunas páginas de códigos heredadas es una heurística, no un veredicto.
Si por el camino necesitas recalcular un digest —para comprobar un lado tras un cambio, o para probar otro algoritmo— carga el archivo en el generador de hash: calcula un algoritmo a la vez, en hexadecimal. Pero la diferencia se encuentra con tamaños y un volcado hexadecimal, no volviendo a hashear.
Que no vuelva a pasar: normalizar para que los hashes coincidan
Encontrar el byte es la mitad del trabajo. Si los dos lados siguen separándose, normalízalos para que los digests coincidan por construcción.
Una nota antes de los comandos: sha256sum es de GNU y no viene en un macOS de fábrica, donde el equivalente es shasum -a 256. Esa sustitución vale para todos los ejemplos de abajo.
Finales de línea. Convierte CRLF a LF antes de hashear. dos2unix lo hace si está instalado —en macOS no viene por defecto— y tr es POSIX, así que funciona en cualquier parte:
tr -d '\r' < winfile.txt | sha256sum # Linux
tr -d '\r' < winfile.txt | shasum -a 256 # macOS
Con el archivo de antes, eso devuelve 911169dd…: exactamente el digest de la versión con LF. (tr -d '\r' elimina todos los retornos de carro, no solo los que cierran una línea; en un archivo de texto bien formado son el mismo conjunto.) Para cortarlo de raíz en un repositorio Git, versiona un .gitattributes con * text=auto eol=lf, de modo que el árbol de trabajo de todo el mundo coincida en lugar de depender del core.autocrlf de cada uno.
Un BOM de UTF-8. El BOM de UTF-8 son exactamente tres bytes al principio, así que saltárselos es lo más sencillo y no involucra escapes de expresiones regulares. Otras codificaciones difieren: el BOM de UTF-16 son dos bytes y el de UTF-32, cuatro. Los comandos siguientes dan por hecho que ya confirmaste que hay un BOM de UTF-8; ejecútalos sobre un archivo que no lo tenga y te comerás tres bytes reales:
tail -c +4 utf8-bomfile.txt | sha256sum # Linux
tail -c +4 utf8-bomfile.txt | shasum -a 256 # macOS
Eso devuelve 2cf24dba…, el digest de hello sin BOM.
Codificación. Lleva ambos lados a una sola codificación primero:
iconv -f UTF-16 -t UTF-8 input.txt | sha256sum
Directorios y comprimidos. No hashees el contenedor. Hashea el contenido y luego hashea la lista ordenada de esos hashes: eso elimina de una vez las marcas de tiempo, el orden de empaquetado y los ajustes de compresión. Ejecútalo dentro del directorio, para que las rutas registradas sean relativas:
# No determinista: el contenedor arrastra marcas de tiempo y orden de entradas
tar -cf - mydir | sha256sum
# Determinista: contenido más rutas relativas, con el orden fijado por sort
( cd mydir && find . -type f -exec sha256sum {} + | sort | sha256sum )
Conviene ser preciso sobre qué cubre ese digest, porque sha256sum imprime la ruta junto a cada hash y la ruta pasa así a formar parte de la entrada. Lo que hashea es el contenido de los archivos más sus rutas relativas. Ignora las marcas de tiempo del directorio y el orden de empaquetado, pero cambia si un archivo se renombra o se mueve, se salta los directorios vacíos y no captura nada sobre enlaces simbólicos, permisos ni propietarios. Si lo ejecutas desde fuera (find mydir …), el propio nombre de la carpeta también entra en el hash, así que dos árboles idénticos con nombres de carpeta distintos no coincidirán. Para responder «¿acabaron estos dos árboles con los mismos archivos, en los mismos sitios?», esto es justo lo que quieres. Si además necesitas permisos y enlaces simbólicos, usa una herramienta pensada para eso.
La lista de comprobación
- ¿El mismo objeto? Misma ruta, mismo tamaño, no un directorio, y no un archivo que aún se está escribiendo.
- ¿Una diferencia real en el digest? ¿O solo mayúsculas frente a minúsculas en el hex, o hex frente a base64? Eso no es ninguna diferencia, y se explica en la guía de checksums enlazada más arriba.
- ¿Hasheando texto? Sospecha de la codificación, un BOM,
LFvsCRLF, un salto de línea final, espacios invisibles o la normalización Unicode; y comprueba que tu herramienta o tu código lee en modo binario. - ¿Hasheando datos estructurados? JSON y compañía admiten muchas serializaciones válidas. Canonicaliza primero y hashea después.
- ¿Hasheando un comprimido? Sospecha de marcas de tiempo, orden de archivos, compresión y metadatos, incluidos
.docx,.xlsxy.jar, que son zips. Hashea el contenido, no el contenedor. - ¿Sigues atascado? Compara tamaños, luego vuelca en hexadecimal y haz diff. El primer byte que difiere ayuda a acotar la investigación, pero no revela automáticamente la causa raíz.
El hilo conductor es el hecho del principio: un hash es determinista, así que un hash distinto prueba que los bytes difieren. «Mismo archivo, hash distinto» nunca es un problema del hash. Es un archivo que se volvió, en silencio, otra secuencia de bytes —un salto de línea, una codificación, un BOM, una reserialización, un reempaquetado— y el hash, haciendo exactamente su trabajo, se negó a fingir lo contrario. Encuentra el byte que cambió y luego normaliza para que no pueda volver a cambiar.