'use strict' // Content hashing for the LAN sync, plus the per-file digest cache // (files/data/lansync-hashes.json, keyed by path with size + mtime). The // cache is machine-local and never travels in a sync. const crypto = require('crypto') const fs = require('fs') const path = require('path') // BLAKE2b is faster than SHA-2; older runtimes fall back to SHA-2. const HASH_ALGO = (() => { for (const algo of ['blake2b512', 'sha512', 'sha256']) { try { crypto.createHash(algo) return algo } catch {} } return 'sha256' })() // Streamed hashing keeps memory flat on huge files. function hashFile(file, onBytes) { return new Promise((resolve, reject) => { const hash = crypto.createHash(HASH_ALGO) const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 }) stream.on('error', reject) if (onBytes) stream.on('data', chunk => onBytes(chunk.length)) stream.on('data', chunk => hash.update(chunk)) stream.on('end', () => resolve(hash.digest('hex'))) stream.resume() }) } // Beyond a quarter million entries, the least recently touched are dropped. const HASH_CACHE_MAX = 250000 // Mid-sync saves are throttled; callers that must not lose entries pass force=true. const SAVE_MIN_MS = 60000 function createHashCache(dataDir) { const FILE = path.join(dataDir, 'lansync-hashes.json') let cache = null let dirty = false let savedAt = 0 function load() { if (cache) return cache try { const stored = JSON.parse(fs.readFileSync(FILE, 'utf8')) cache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {} } catch { cache = {} } return cache } // Compact JSON: a full cache writes in tens of ms. function save(force) { if (!dirty || !cache) return if (!force && Date.now() - savedAt < SAVE_MIN_MS) return const names = Object.keys(cache) if (names.length > HASH_CACHE_MAX) { names.sort((a, b) => (cache[b][3] || 0) - (cache[a][3] || 0)) const trimmed = {} for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = cache[name] cache = trimmed } dirty = false savedAt = Date.now() try { fs.mkdirSync(path.dirname(FILE), { recursive: true }) const tmp = `${FILE}.tmp` fs.writeFileSync(tmp, JSON.stringify({ v: 1, algo: HASH_ALGO, files: cache })) fs.renameSync(tmp, FILE) } catch {} } // The remembered digest, or null when it has to be read again. A hit marks // the cache dirty only when the timestamp is stale. function hit(rel, size, mtimeMs) { const known = load()[rel] if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null if (Date.now() - (known[3] || 0) > 86400000) { known[3] = Date.now() dirty = true } return known[2] } function remember(rel, size, mtimeMs, hash) { load()[rel] = [size, mtimeMs, hash, Date.now()] dirty = true } // Unchanged size + mtime -> cached digest; otherwise read and store. async function cachedHash(rel, abs, size, mtimeMs, onBytes) { const known = hit(rel, size, mtimeMs) if (known) return known const hash = await hashFile(abs, onBytes) if (hash) remember(rel, size, mtimeMs, hash) return hash } // Settings view "clear cache" and stale files go through here. function clear() { cache = null dirty = false savedAt = 0 try { fs.rmSync(FILE, { force: true }) fs.rmSync(`${FILE}.tmp`, { force: true }) } catch {} } return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear } } module.exports = { createHashCache, hashFile, HASH_ALGO }