132 lines
4.6 KiB
JavaScript
132 lines
4.6 KiB
JavaScript
'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 }
|