PLWL/src/lansync-manifest.js

168 lines
6.2 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.
// `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 }