'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. async function scanTree(abs, rel, out, mtimes) { 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()) { out.push({ path: relChild, type: 'dir' }) 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 {} out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }) 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; `force` skips hashing (receiver replaces everything). async function buildManifest(roots, force, 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 {} entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) }) } else if (st.isDirectory()) { entries.push({ path: rel, type: 'dir' }) await scanTree(abs, rel, entries, mtimes) } 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, so the bar starts empty const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0) if (!force && files.length) { let done = 0 let hashed = 0 await mapLimit(files, concurrency, 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 } 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) }) } hash.save() return entries } return { buildManifest, scanTree, portableLinkTarget } } module.exports = { createManifestBuilder }