Deduplicación eficiente de archivos con SHA-256 y Node.js
Usa el resumen SHA-256 de un archivo como clave única de base de datos para rechazar subidas repetidas, incluso si los nombres de archivo difieren. Esta guía crea un servicio local de Node.js, envía archivos con cURL y comprueba que los registros de deduplicación persistan tras un reinicio.
Comprende la deduplicación basada en contenido
El servidor guarda cada archivo entrante con un nombre generado, procesa sus bytes con SHA-256
mediante un flujo e intenta insertar el resumen en SQLite. Si la inserción tiene éxito, conserva
el archivo. Si hay un conflicto, elimina la nueva copia y devuelve HTTP 409 Conflict.
La decisión importante ocurre en una sola sentencia de base de datos: INSERT … ON CONFLICT(hash) DO NOTHING.
El comportamiento de UPSERT en SQLite permite que la clave única
resuelva los conflictos entre subidas concurrentes. Buscar primero un resumen e insertarlo después
permitiría que dos solicitudes detectaran su ausencia. La clave primaria ya garantiza la unicidad;
no hace falta un segundo índice de hashes.
Esto detecta bytes idénticos. Cambiar el nombre de un archivo no altera su resumen, pero volver a codificar una imagen o modificar los metadatos incrustados sí puede hacerlo. No detecta imágenes visualmente similares.
¿Por qué no usar MD5?
MD5 no es adecuado cuando importa la resistencia a colisiones, incluso cuando alguien podría subir deliberadamente archivos distintos con el mismo resumen. SHA-256 proporciona resistencia a colisiones, no una garantía matemática de que no puedan existir colisiones. Este ejemplo trata los resúmenes coincidentes como duplicados. Si tu aplicación requiere comprobar la igualdad exacta, compara los bytes almacenados con los entrantes antes de descartar una subida coincidente y proporciona una forma independiente de almacenar una colisión.
Configura el proyecto
Usa una shell de Linux, cURL, Node.js 24.15.0 y Corepack con Yarn 4.12.0 disponible. El ejemplo
también funciona en Node.js 26.8.1. El soporte integrado para TypeScript
de Node ejecuta el archivo .ts guardado sin una etapa de compilación; no comprueba sus tipos.
Pega lo siguiente en una terminal. Crea un proyecto nuevo y vuelve al directorio original.
Si file-deduplication ya existe, elige otro directorio padre; los comandos se niegan a
reutilizarlo.
(
mkdir file-deduplication &&
cd file-deduplication &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableScripts: true' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 sqlite3@5.1.7
)
Espera a que la instalación finalice correctamente antes de continuar. sqlite3
incluye un enlace nativo; si su binario precompilado no está disponible para tu plataforma, su
guía de instalación describe los requisitos de compilación.
Las versiones anteriores usan SQLite 3.44.2 en el entorno de Linux probado.
El archivo local yarn.lock hace que este proyecto sea independiente, y
node-modules permite que node resuelva sus paquetes directamente.
Los scripts de instalación están habilitados para el enlace nativo de SQLite. El repositorio
sqlite3 ahora está archivado, así que trata esto como un ejemplo local con
versiones fijas y elige un controlador de base de datos con mantenimiento activo antes de adaptarlo
para un nuevo servicio que vayas a desplegar.
Crea un manejador local de subidas
Guarda el bloque completo como file-deduplication/server.ts. Este servicio escucha en
127.0.0.1 y no tiene autenticación. Acepta bytes de archivo arbitrarios, incluidos
archivos vacíos, y no los procesa ni los sirve. Mantenlo en un entorno local; el filtrado por MIME
no demostraría que una subida es segura.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { rm } from 'node:fs/promises'
import express, { type ErrorRequestHandler } from 'express'
import multer from 'multer'
import sqlite3 from 'sqlite3'
async function hashFile(filePath: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) hash.update(chunk)
return hash.digest('hex')
}
async function storeFile(
db: sqlite3.Database,
filePath: string,
name: string,
size: number,
): Promise<{ hash: string; stored: boolean }> {
let stored = false
try {
const hash = await hashFile(filePath)
const changes = await new Promise<number>((resolve, reject) => {
db.run(
`INSERT INTO files (hash, original_name, file_path, size)
VALUES (?, ?, ?, ?) ON CONFLICT(hash) DO NOTHING`,
[hash, name, filePath, size],
function (error) {
if (error) reject(error)
else resolve(this.changes)
},
)
})
stored = changes === 1
return { hash, stored }
} finally {
// Finish cleanup before the route sends a duplicate or failure response.
if (!stored) await rm(filePath, { force: true })
}
}
async function main(): Promise<void> {
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error('PORT must be an integer from 1 to 65535')
process.exit(1)
}
const db = await new Promise<sqlite3.Database>((resolve, reject) => {
const database = new sqlite3.Database('deduplication.db', (error) => {
if (error) reject(error)
else resolve(database)
})
})
await new Promise<void>((resolve, reject) => {
db.exec(
`CREATE TABLE IF NOT EXISTS files (
hash TEXT NOT NULL PRIMARY KEY,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
size INTEGER NOT NULL,
upload_date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
(error) => (error ? reject(error) : resolve()),
)
})
const app = express()
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
})
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Send one file in the file field' })
const { path, originalname, size } = req.file
const { hash, stored } = await storeFile(db, path, originalname, size)
if (!stored) return res.status(409).json({ error: 'Duplicate file detected', hash })
return res.status(201).json({ message: 'File stored', hash, size })
})
app.get('/files', (_req, res, next) => {
db.all(
'SELECT original_name AS name, hash, size, upload_date FROM files ORDER BY hash',
(error, rows) => {
if (error) return next(error)
res.json(rows)
},
)
})
const handleError: ErrorRequestHandler = (error: unknown, _req, res, _next) => {
if (error instanceof multer.MulterError) {
const status = error.code === 'LIMIT_FILE_SIZE' ? 413 : 400
res.status(status).json({ error: 'Send one file of at most 10 MiB and no text fields' })
return
}
console.error('Request failed; check the database and upload directory')
res.status(500).json({ error: 'Could not complete the request' })
}
app.use(handleError)
await new Promise<void>((resolve, reject) => {
app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) reject(error)
else resolve()
})
})
console.log(`Listening on http://127.0.0.1:${port}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : 'STARTUP_FAILED'
console.error(`Could not start server (${code}); check the port and storage permissions`)
process.exit(1)
})
El almacenamiento en disco de Multer
asigna un nombre de archivo generado en lugar de usar el nombre del cliente como ruta. SQLite
conserva el primer nombre aceptado como metadato. El uso de un callback con una
function convencional en db.run es intencional:
this.changes pertenece a esa sentencia completada
y distingue una fila insertada de un conflicto que no produjo cambios.
Inicia el servidor
Desde el mismo directorio padre donde ejecutaste la configuración, pega:
(
cd file-deduplication &&
node server.ts
)
Espera a que aparezca Listening on http://127.0.0.1:3000. Si recibes EADDRINUSE, detén el proceso que ya escucha
o establece PORT=3001 antes de node server.ts y usa ese puerto en todas las solicitudes siguientes.
El callback de inicio comprueba los errores porque
Express 5 notifica allí los fallos al vincularse al puerto.
Es necesario resolver cualquier error de permisos de la base de datos o del directorio antes de que
el servidor pueda iniciarse.
Sube un archivo, repite la subida y cambia los bytes
En una segunda terminal, sube seis bytes, incluido el salto de línea:
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=hello.txt' http://127.0.0.1:3000/upload
La respuesta tiene el estado 201 Created y este cuerpo JSON:
{"message":"File stored","hash":"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03","size":6}
Sube los mismos bytes con un nombre diferente:
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=renamed.txt' http://127.0.0.1:3000/upload
La respuesta esperada es 409 Conflict, con error establecido en Duplicate file detected y el mismo hash.
Se conserva la primera copia almacenada. Estos comandos de subida omiten intencionalmente la opción
-f de cURL para que puedas inspeccionar la respuesta esperada
409; que cURL finalice correctamente no significa por sí solo que el servidor haya aceptado un archivo.
Ahora envía bytes diferentes con el primer nombre de archivo y luego envía un archivo vacío:
printf 'different\n' | curl -sS -i -F 'file=@-;filename=hello.txt' http://127.0.0.1:3000/upload
curl -sS -i -F 'file=@/dev/null;filename=empty.bin' http://127.0.0.1:3000/upload
Ambas solicitudes devuelven 201, con tamaños de 10 y 0.
Repetir cualquiera de ellas devuelve 409.
Ahora hay tres archivos en file-deduplication/uploads/ y tres registros en la base de datos, aunque
dos archivos aceptados tienen el mismo nombre original. Lista esos registros con:
curl -fsS http://127.0.0.1:3000/files
Cuando finalicen todas las solicitudes, detén el servidor con Ctrl+C, vuelve a iniciarlo con el
mismo comando y repite la primera subida. Sigue devolviendo 409.
Tanto deduplication.db como uploads/ se encuentran en el directorio
del proyecto, así que mantenlos juntos y reinicia desde ese directorio. El ejemplo agrega contenido
nuevo y conserva la primera copia del contenido repetido; reiniciar no borra ninguno de los dos
almacenes.
Cuando se superponen subidas de contenido nuevo idéntico, la inserción con clave única admite una
solicitud con 201. Las demás reciben 409 después de que se eliminan sus archivos adicionales.
Las solicitudes con archivos faltantes, nombres de campo incorrectos, archivos adicionales o campos
de texto reciben 400; los archivos de más de 10 MiB reciben 413.
Un fallo de almacenamiento devuelve un 500 genérico, así que revisa la
terminal del servidor y el sistema de archivos antes de reintentarlo.
Maneja archivos grandes de forma eficiente
La API de hash incremental
permite que hashFile lea fragmentos en lugar de cargar todo el archivo en memoria.
Multer primero escribe la subida completa en disco y luego el cálculo del hash vuelve a leerla.
Por tanto, la detección de duplicados ahorra almacenamiento permanente, pero no evita el consumo de
ancho de banda de la subida ni el uso temporal del disco.
Cada llamada a hash.update() consume CPU en el hilo principal. El límite de 10 MiB
por archivo acota el tamaño de entrada de este ejemplo local; no limita la cantidad de solicitudes
simultáneas ni el uso total del disco. Las cargas de trabajo mayores necesitan límites de
concurrencia y almacenamiento antes de aumentar ese tope.
Consejos de rendimiento, almacenamiento y seguridad
La inserción en SQLite es atómica, pero la escritura del archivo y la inserción en la base de datos no forman una sola transacción. Un fallo entre ambas puede dejar un archivo sin referencia. Eliminar o corromper un archivo almacenado no elimina su registro de hash, por lo que una subida posterior puede rechazarse aunque falten los bytes almacenados. La limpieza también puede fallar si cambian los permisos de almacenamiento. Este ejemplo no reconcilia esos casos, no verifica los archivos almacenados en cada consulta ni promete durabilidad ante un corte de energía.
Haz una copia de seguridad de la base de datos y los archivos como un conjunto coherente mientras el servicio esté detenido. Antes de adaptar esto para subidas públicas, agrega autenticación, comprobaciones de propiedad, cuotas y un proceso de recuperación que reconcilie los registros con los archivos. Una respuesta de duplicado de alcance global puede revelar que otra persona subió cierto contenido; elige deliberadamente el alcance de la deduplicación.
