'use strict' const fs = require('fs') const fsp = require('fs').promises const path = require('path') // accessSync instead of existsSync: same answer, one syscall fewer. function pathExists(p) { try { fs.accessSync(p) return true } catch { return false } } // Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...). function dedupeDir(base, name) { const candidate = path.join(base, name) if (!fs.existsSync(candidate)) return candidate let n = 2 while (fs.existsSync(path.join(base, `${name} (${n})`))) n++ return path.join(base, `${name} (${n})`) } // Events are rate limited to ~10/s; `force` pushes the final value through. const PROGRESS_MIN_MS = 100 function progressGate(emit) { let last = 0 let pending = null let timer = null const flush = () => { timer = null if (!pending) return last = Date.now() const data = pending pending = null emit(data) } const push = (data, force) => { pending = data const wait = PROGRESS_MIN_MS - (Date.now() - last) if (force || wait <= 0) { if (timer) clearTimeout(timer) flush() } else if (!timer) { timer = setTimeout(flush, wait) if (timer.unref) timer.unref() } } push.done = () => { if (timer) clearTimeout(timer) flush() } return push } // Run `fn` over `items` with at most `limit` in flight, results in order. async function mapLimit(items, limit, fn) { const out = new Array(items.length) let next = 0 const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const i = next++ out[i] = await fn(items[i], i) } }) await Promise.all(workers) return out } // What a tree copy will move. Symlinks are recreated, not read. `skipDir` // leaves out one directory name (redistributables). async function treeSize(root) { let files = 0 let bytes = 0 // a symlinked root is the whole tree; never measure through it let rootStat = null try { rootStat = await fsp.lstat(root) } catch { return { files, bytes } } if (!rootStat.isDirectory()) return { files: 1, bytes: rootStat.isSymbolicLink() ? 0 : rootStat.size } const walk = async dir => { let entries try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isSymbolicLink()) { files++ } else if (entry.isDirectory()) { await walk(full) } else if (entry.isFile()) { files++ try { bytes += (await fsp.stat(full)).size } catch {} } } } await walk(root) return { files, bytes } } // Copy a tree with symlinks recreated and permissions carried (copyFile // drops both); reports cumulative { done, bytes } through onProgress. async function copyTree(src, dst, onProgress) { let done = 0 let bytes = 0 const gate = onProgress ? progressGate(onProgress) : null const tick = () => { if (gate) gate({ done, bytes }, false) } const walk = async (from, to) => { let entries try { entries = await fsp.readdir(from, { withFileTypes: true }) } catch { return } await fsp.mkdir(to, { recursive: true }) for (const entry of entries) { const s = path.join(from, entry.name) const d = path.join(to, entry.name) if (entry.isSymbolicLink()) { let target = null try { target = await fsp.readlink(s) } catch {} if (!target) continue try { await fsp.symlink(target, d) } catch {} done++ tick() } else if (entry.isDirectory()) { await walk(s, d) } else if (entry.isFile()) { let size = 0 try { size = (await fsp.stat(s)).size } catch {} try { await fsp.copyFile(s, d) await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777) } catch {} done++ bytes += size tick() } } } await walk(src, dst) if (gate) gate({ done, bytes }, true) return { done, bytes } } // Imported trees come from media that lose permissions (archives, FAT, a // read-only backup drive), and copyFile keeps whatever the source had: every // node gets owner rwx and `x` mirrored from `r`, so the app can run its own // binaries and write its own files. async function chmodTree(root) { const desired = mode => mode | 0o700 | ((mode & 0o444) >> 2) let files = 0 let dirs = 0 const walk = async dir => { let entries try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isSymbolicLink()) continue if (entry.isDirectory()) { await walk(full) } else if (!entry.isFile()) { continue } try { const before = (await fsp.stat(full)).mode & 0o7777 const after = desired(before) if (after !== before) { await fsp.chmod(full, after) if (entry.isDirectory()) dirs++ else files++ } } catch {} } } await walk(root) return { files, dirs } } // One file, streamed, with byte progress: copyFile says nothing until a // multi-gigabyte copy is over. The bytes land on a .part name and are renamed // at the end, so nothing can ever read a half-written document. async function copyFileProgress(src, dst, onProgress) { const stat = await fsp.stat(src) if (!stat.isFile()) throw new Error('not a file: ' + src) const gate = onProgress ? progressGate(onProgress) : null await fsp.mkdir(path.dirname(dst), { recursive: true }) const part = dst + '.part' let bytes = 0 try { await new Promise((resolve, reject) => { const input = fs.createReadStream(src) const output = fs.createWriteStream(part) const fail = err => { input.destroy() output.destroy() reject(err) } input.on('error', fail) output.on('error', fail) input.on('data', chunk => { bytes += chunk.length if (gate) gate({ done: 0, bytes }, false) }) output.on('finish', resolve) input.pipe(output) }) await fsp.chmod(part, stat.mode & 0o777) await fsp.rename(part, dst) } catch (err) { try { await fsp.rm(part, { force: true }) } catch {} throw err } if (gate) { gate({ done: 1, bytes: stat.size }, true) gate.done() } return { files: 1, bytes: stat.size } } // Delete deepest-first with progress through onProgress; symlinks are // unlinked, never followed. async function removeTree(root, onProgress) { let files = 0 let dirs = 0 let bytes = 0 const gate = onProgress ? progressGate(onProgress) : null const tick = () => { if (gate) gate({ files, dirs, bytes }, false) } // a symlinked root is removed as itself, never walked through let rootStat = null try { rootStat = await fsp.lstat(root) } catch { return { files, dirs, bytes } } if (!rootStat.isDirectory()) { files = 1 bytes = rootStat.isSymbolicLink() ? 0 : rootStat.size try { await fsp.unlink(root) } catch {} if (gate) gate({ files, dirs, bytes }, true) return { files, dirs, bytes } } const walk = async dir => { let entries try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { return false } for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isDirectory()) { await walk(full) try { await fsp.rmdir(full) } catch {} dirs++ tick() continue } let size = 0 try { const st = await fsp.lstat(full) size = st.isFile() ? st.size : 0 } catch {} try { await fsp.unlink(full) } catch {} files++ bytes += size tick() } try { await fsp.rmdir(dir) dirs++ } catch {} return true } await walk(root) if (gate) gate({ files, dirs, bytes }, true) return { files, dirs, bytes } } module.exports = { pathExists, dedupeDir, copyTree, copyFileProgress, treeSize, removeTree, chmodTree, progressGate, mapLimit }