'use strict' // Sender-side hashing: walk sync units into a manifest of dirs, links, files. // Factory over the path guards and hash store, one per launcher state. const path = require('path') const fs = require('fs') const fsp = fs.promises function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) { // Targets inside the launcher data travel relative (the mirrored layout is // the same on both machines); outside targets stay as they are. function portableLinkTarget(linkAbs, target) { if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target const rel = path.relative(path.dirname(linkAbs), target) if (!rel || path.isAbsolute(rel)) return target const root = path.resolve(filesRoot) const resolved = path.resolve(path.dirname(linkAbs), rel) if (resolved !== root && !resolved.startsWith(root + path.sep)) return target return rel } // Walk without following symlinks (dosdevices etc. are part of the sync). // Iterative, with one directory's stat/readlink calls in flight together. // `emit` (optional) streams dirs and links out while the walk continues; // file entries only land in `out` (their digest comes later). async function scanTree(abs, rel, out, mtimes, emit) { const queue = [[abs, rel]] while (queue.length) { const [dirAbs, dirRel] = queue.pop() let entries try { entries = await fsp.readdir(dirAbs, { withFileTypes: true }) } catch { continue } const dirs = [] const leaves = [] for (const entry of entries) { const relChild = `${dirRel}/${entry.name}` if (entry.isDirectory()) { const record = { path: relChild, type: 'dir' } out.push(record) if (emit) await emit(record) dirs.push([path.join(dirAbs, entry.name), relChild]) } else { leaves.push(entry) } } await mapLimit(leaves, concurrency, async entry => { const absChild = path.join(dirAbs, entry.name) const relChild = `${dirRel}/${entry.name}` if (entry.isSymbolicLink()) { let target = '' try { target = await fsp.readlink(absChild) } catch {} const record = { path: relChild, type: 'link', target: portableLinkTarget(absChild, target) } out.push(record) if (emit) await emit(record) return } let st try { st = await fsp.stat(absChild) } catch { return } if (!st.isFile()) return // mode travels with the file out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) // mtimes are machine-local: kept out of the manifest if (mtimes) mtimes.set(relChild, st.mtimeMs || 0) }) queue.push(...dirs) } } // Manifest of one unit, streamed: `handlers` may carry onEntry (a Promise // per entry, awaited - backpressure), onTotal and onProgress. File entries // go out as their digests finish, so the receiver compares while this side // is still hashing; hashing itself stays parallel, a batch at a time. async function buildManifestStream(roots, force, handlers) { const onEntry = handlers && handlers.onEntry const onTotal = handlers && handlers.onTotal const onProgress = handlers && handlers.onProgress const entries = [] const mtimes = new Map() for (const rel of roots) { const abs = filesPath(rel) if (!abs) continue let st try { st = await fsp.lstat(abs) } catch { continue } if (st.isSymbolicLink()) { let target = '' try { target = await fsp.readlink(abs) } catch {} const record = { path: rel, type: 'link', target: portableLinkTarget(abs, target) } entries.push(record) if (onEntry) await onEntry(record) } else if (st.isDirectory()) { const record = { path: rel, type: 'dir' } entries.push(record) if (onEntry) await onEntry(record) await scanTree(abs, rel, entries, mtimes, onEntry) } else if (st.isFile()) { entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null }) mtimes.set(rel, st.mtimeMs || 0) } } const files = entries.filter(e => e.type === 'file') // total size is known before hashing: the receiver sizes its compare bar // from this, the sender's popup from onProgress const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) if (onTotal) await onTotal(totalBytes) let done = 0 let hashed = 0 // one batch hashes with the whole disk queue behind it; its entries go // on the wire in order before the next batch starts for (let i = 0; i < files.length; i += concurrency) { const batch = files.slice(i, i + concurrency) if (!force) { await Promise.all(batch.map(async entry => { const abs = filesPath(entry.path) try { // unchanged size + mtime: the cached digest still holds const mtimeMs = mtimes.get(entry.path) || 0 entry.hash = abs ? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => { hashed += bytes if (onProgress) onProgress(done, files.length, hashed, totalBytes) }) : null } catch { entry.hash = null } })) } for (const entry of batch) { done++ if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over if (onProgress) onProgress(done, files.length, hashed, totalBytes) if (onEntry) await onEntry(entry) } } hash.save() return entries } // Buffering variant for callers that want the whole manifest first. async function buildManifest(roots, force, onProgress) { const entries = [] await buildManifestStream(roots, force, { onProgress, onEntry: entry => { entries.push(entry) } }) return entries } return { buildManifest, buildManifestStream, scanTree, portableLinkTarget } } module.exports = { createManifestBuilder }