Deduplicação eficiente de arquivos com SHA-256 e Node.js
Use o digest SHA-256 de um arquivo como chave única no banco de dados para rejeitar uploads repetidos, mesmo quando os nomes dos arquivos forem diferentes. Este passo a passo cria um serviço local em Node.js, envia arquivos com cURL e verifica se os registros de deduplicação sobrevivem a uma reinicialização.
Entenda a deduplicação baseada em conteúdo
O servidor salva cada arquivo recebido com um nome gerado, passa seus bytes em stream pelo SHA-256
e tenta inserir o digest no SQLite. Uma inserção bem-sucedida mantém o arquivo. Uma inserção
conflitante remove a nova cópia e retorna HTTP 409 Conflict.
A decisão importante acontece em uma única instrução do banco de dados: INSERT … ON CONFLICT(hash) DO NOTHING.
O comportamento de UPSERT do SQLite permite que a chave única
arbitre uploads concorrentes. Consultar um digest primeiro e inseri-lo depois permitiria que duas
requisições observassem que ele está ausente. A chave primária já garante a unicidade; um segundo
índice de hash é desnecessário.
Isso detecta bytes idênticos. Renomear um arquivo não altera seu digest, mas recodificar uma imagem ou alterar metadados incorporados pode alterá-lo. Essa abordagem não encontra imagens visualmente semelhantes.
Por que não usar MD5?
O MD5 não é adequado quando a resistência a colisões importa, inclusive quando alguém poderia enviar deliberadamente arquivos diferentes com o mesmo digest. O SHA-256 oferece resistência a colisões, não uma garantia matemática de que colisões não possam existir. Este exemplo trata digests iguais como duplicatas. Se a sua aplicação exigir uma verificação exata de igualdade, compare os bytes armazenados e os recebidos antes de descartar um upload correspondente e ofereça uma forma separada de armazenar uma colisão.
Configure o projeto
Tenha disponíveis um shell Linux, cURL, Node.js 24.15.0 e Corepack com Yarn 4.12.0. O
exemplo também roda no Node.js 26.8.1. O suporte nativo a TypeScript
do Node executa o arquivo .ts salvo sem uma etapa de compilação; ele não
faz a verificação de tipos.
Cole isto em um terminal. Os comandos criam um novo projeto e retornam ao seu diretório original.
Se file-deduplication já existir, escolha um novo diretório pai; os comandos se recusam
a reutilizá-lo.
(
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
)
Aguarde a instalação terminar com sucesso antes de continuar. sqlite3 inclui
um binding nativo; se o binário pré-compilado do pacote não estiver disponível para a sua
plataforma, o guia de instalação dele descreve os pré-requisitos de
build. As versões acima usam o SQLite 3.44.2 na configuração Linux testada. O
yarn.lock local torna este um projeto independente, e
node-modules permite que node resolva seus pacotes
diretamente. Os scripts de instalação estão habilitados para o binding nativo do SQLite. O
repositório sqlite3 agora está arquivado, então trate isto como um exemplo
local com versões fixadas e escolha um driver de banco de dados mantido antes de adaptá-lo para um
novo serviço implantado.
Crie um handler local de upload
Salve o bloco completo como file-deduplication/server.ts. Este serviço escuta em
127.0.0.1 e não tem autenticação. Ele aceita bytes de arquivo arbitrários,
incluindo arquivos vazios, e não os processa nem os serve. Mantenha-o local; filtrar por tipo MIME
não comprovaria que um upload é seguro.
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)
})
O armazenamento em disco do Multer
atribui um nome de arquivo gerado em vez de usar o nome enviado pelo cliente como caminho. O SQLite
mantém o primeiro nome aceito como metadado. O callback function convencional
em db.run é intencional: this.changes pertence a essa
instrução concluída e distingue uma linha inserida de um conflito
que não fez nada.
Inicie o servidor
No mesmo diretório pai onde você executou a configuração, cole:
(
cd file-deduplication &&
node server.ts
)
Aguarde Listening on http://127.0.0.1:3000. Se aparecer EADDRINUSE, encerre o
listener existente ou defina PORT=3001 antes de node server.ts
e use essa porta em todas as requisições abaixo. O callback de inicialização verifica erros porque
o Express 5 informa falhas de bind nesse ponto. Um erro de permissão
do banco de dados ou do diretório precisa ser corrigido antes que o servidor possa iniciar.
Faça upload, repita e altere os bytes
Em um segundo terminal, faça upload de seis bytes, incluindo a quebra de linha:
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=hello.txt' http://127.0.0.1:3000/upload
A resposta tem status 201 Created e este corpo JSON:
{"message":"File stored","hash":"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03","size":6}
Faça upload dos mesmos bytes com outro nome:
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=renamed.txt' http://127.0.0.1:3000/upload
Espere 409 Conflict, com error definido como
Duplicate file detected e o mesmo hash. A primeira cópia armazenada permanece. Esses
comandos de upload omitem intencionalmente a opção -f do cURL para que
você possa inspecionar a resposta 409 esperada; um código de saída
bem-sucedido do cURL, por si só, não significa que o servidor aceitou um arquivo.
Agora envie bytes diferentes usando o primeiro nome de arquivo e, em seguida, envie um arquivo vazio:
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
Ambos retornam 201, com tamanhos 10 e
0. Repetir qualquer uma das requisições retorna
409. Agora há três arquivos em file-deduplication/uploads/ e três
registros no banco de dados, embora dois arquivos aceitos tenham o mesmo nome original. Liste esses
registros com:
curl -fsS http://127.0.0.1:3000/files
Depois que todas as requisições terminarem, pare o servidor com Ctrl+C, inicie-o novamente com
o mesmo comando e repita o primeiro upload. Ele ainda retorna 409. Tanto
deduplication.db quanto uploads/ ficam no diretório do projeto,
então mantenha-os juntos e reinicie a partir desse diretório. O exemplo acrescenta conteúdo novo e
mantém a primeira cópia de conteúdo repetido; reiniciar não limpa nenhum dos dois armazenamentos.
Em uploads concorrentes de um mesmo conteúdo novo, a inserção única admite uma requisição com
201. As demais recebem 409 depois que seus
arquivos extras são removidos. Arquivos ausentes, nomes de campo errados, arquivos extras e campos
de texto recebem 400; arquivos maiores que 10 MiB recebem
413. Uma falha de armazenamento retorna um 500
genérico, então verifique o terminal do servidor e o sistema de arquivos antes de tentar novamente.
Lide com arquivos grandes de forma eficiente
A API de hash incremental
permite que hashFile leia blocos em vez de manter o arquivo inteiro em buffer.
O Multer primeiro grava o upload completo em disco e, depois, o cálculo do hash lê o arquivo
novamente. Por isso, a detecção de duplicatas economiza armazenamento retido, mas não evita a
largura de banda do upload nem o uso temporário de disco.
Cada chamada de hash.update() consome CPU na thread principal. O limite de 10 MiB
por arquivo restringe o tamanho da entrada deste exemplo local; ele não limita o número de
requisições simultâneas nem o uso total de disco. Cargas de trabalho maiores precisam de limites de
concorrência e de armazenamento antes que esse teto seja aumentado.
Dicas de desempenho, armazenamento e segurança
A inserção no SQLite é atômica, mas a gravação do arquivo e a inserção no banco de dados não são uma única transação. Uma queda do processo entre elas pode deixar um arquivo sem referência. Excluir ou corromper um arquivo armazenado não remove seu registro de hash, então um upload posterior pode ser rejeitado mesmo que os bytes armazenados estejam faltando. A limpeza também pode falhar se as permissões de armazenamento mudarem. Este exemplo não reconcilia esses casos, não verifica os arquivos armazenados a cada consulta nem promete durabilidade em caso de queda de energia.
Faça backup do banco de dados e dos arquivos como um par consistente enquanto o serviço estiver parado. Antes de adaptar isto para uploads públicos, adicione autenticação, verificações de propriedade, cotas e um processo de recuperação que reconcilie os registros com os arquivos. Uma resposta global de duplicata pode revelar que outra pessoa fez upload de determinado conteúdo; escolha o escopo da deduplicação de forma deliberada.
