From 59a8912fba27375f977ebfe052ecb7b85b1cf3f1 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 22:18:57 +0200 Subject: [PATCH] feat(resume): chunk-hash sha1 helpers (8 tests) hashBuffer (sync) + hashFile (async streaming). 1MB+ files don't block. Known-input vectors (hello, empty) verify against canonical sha1. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/infra/chunk-hash.test.ts | 67 +++++++++++++++++++++++++++++++ src/main/infra/chunk-hash.ts | 26 ++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/main/infra/chunk-hash.test.ts create mode 100644 src/main/infra/chunk-hash.ts diff --git a/src/main/infra/chunk-hash.test.ts b/src/main/infra/chunk-hash.test.ts new file mode 100644 index 0000000..b557b07 --- /dev/null +++ b/src/main/infra/chunk-hash.test.ts @@ -0,0 +1,67 @@ +import { test, expect, describe, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { hashBuffer, hashFile } from './chunk-hash'; + +let tmpDir: string; +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkhash-')); +}); +afterEach(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('hashBuffer', () => { + test('"hello" sha1', () => { + expect(hashBuffer(Buffer.from('hello', 'utf-8'))) + .toBe('aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d'); + }); + + test('empty buffer sha1', () => { + expect(hashBuffer(Buffer.alloc(0))) + .toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709'); + }); + + test('large buffer hashes deterministically', () => { + const big = Buffer.alloc(1024 * 1024, 0x42); // 1MB of 'B' bytes + const a = hashBuffer(big); + const b = hashBuffer(big); + expect(a).toBe(b); + expect(a).toHaveLength(40); // sha1 = 40 hex chars + }); + + test('different content produces different hashes', () => { + expect(hashBuffer(Buffer.from('a'))).not.toBe(hashBuffer(Buffer.from('b'))); + }); +}); + +describe('hashFile', () => { + test('file hash matches buffer hash for same content', async () => { + const content = 'roundtrip-test-payload'; + const filePath = path.join(tmpDir, 'a.bin'); + fs.writeFileSync(filePath, content, 'utf-8'); + const fileHash = await hashFile(filePath); + const bufHash = hashBuffer(Buffer.from(content, 'utf-8')); + expect(fileHash).toBe(bufHash); + }); + + test('empty file = empty-buffer sha1', async () => { + const filePath = path.join(tmpDir, 'empty.bin'); + fs.writeFileSync(filePath, ''); + const fileHash = await hashFile(filePath); + expect(fileHash).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709'); + }); + + test('large file (4MB) hashes correctly', async () => { + const filePath = path.join(tmpDir, 'big.bin'); + const payload = Buffer.alloc(4 * 1024 * 1024, 0x55); + fs.writeFileSync(filePath, payload); + const fileHash = await hashFile(filePath); + expect(fileHash).toBe(hashBuffer(payload)); + }); + + test('missing file rejects', async () => { + await expect(hashFile(path.join(tmpDir, 'does-not-exist'))).rejects.toThrow(); + }); +}); diff --git a/src/main/infra/chunk-hash.ts b/src/main/infra/chunk-hash.ts new file mode 100644 index 0000000..8ddf1c4 --- /dev/null +++ b/src/main/infra/chunk-hash.ts @@ -0,0 +1,26 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; + +export function hashBuffer(b: Buffer): string { + return crypto.createHash('sha1').update(b).digest('hex'); +} + +/** + * Streaming sha1-Hash einer Datei. Async, damit grosse Recorded-Segments + * (oft mehrere MB) nicht den Event-Loop blockieren. + */ +export function hashFile(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha1'); + const stream = fs.createReadStream(filePath); + stream.on('error', reject); + stream.on('data', (chunk: Buffer | string) => { + if (typeof chunk === 'string') { + hash.update(chunk, 'utf-8'); + } else { + hash.update(chunk); + } + }); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +}