118 lines
3.6 KiB
JavaScript
118 lines
3.6 KiB
JavaScript
'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 }
|