Files
Nyaruru_modifier_public/tool/lib/files.js
T

146 lines
10 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');
const { canonical, samePath, contains } = require('./safety');
const MANIFEST = 'backup-manifest.json';
const COMPLETE = '.nyaruru-sandbox-complete.json';
const INCOMPLETE = '.nyaruru-sandbox-incomplete';
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const saveNames = dir => fs.existsSync(dir) ? fs.readdirSync(dir).filter(n => /\.rmmzsave$/i.test(n)).sort() : [];
function safeFile(dir, name) {
if (path.basename(name) !== name || !/\.rmmzsave$/i.test(name)) throw new Error('非法存档文件名');
const file = path.join(dir, name), st = fs.lstatSync(file);
if (!st.isFile() || st.isSymbolicLink()) throw new Error('存档必须是普通文件:' + name);
return file;
}
function backupSaves(root, sourceDir, label = 'auto') {
const saveDir = path.join(canonical(sourceDir), 'save');
if (!fs.existsSync(saveDir) || !samePath(saveDir, path.join(sourceDir, 'save')) || fs.lstatSync(saveDir).isSymbolicLink()) throw new Error('存档目录不存在或是目录链接');
const names = saveNames(saveDir);
if (!names.length) throw new Error('没有找到 .rmmzsave 存档,未创建空备份');
fs.mkdirSync(root, { recursive: true });
const dest = fs.mkdtempSync(path.join(root, `${label.replace(/[^a-z0-9_-]/gi, '')}_${new Date().toISOString().replace(/[:.]/g, '-')}_`));
// 未完成目录没有 manifest,不会被列为可恢复备份。
const files = names.map(name => ({ name, sha256: hash(safeFile(saveDir, name)) }));
for (const f of files) fs.copyFileSync(safeFile(saveDir, f.name), path.join(dest, f.name), fs.constants.COPYFILE_EXCL);
if (JSON.stringify(names) !== JSON.stringify(saveNames(saveDir)) || files.some(f => hash(safeFile(saveDir, f.name)) !== f.sha256 || hash(path.join(dest, f.name)) !== f.sha256)) {
throw new Error('备份期间存档发生变化或复制校验失败,请停止存档操作后重试。未完成目录:' + dest);
}
const manifest = { version: 1, sourceDir: canonical(sourceDir), createdAt: new Date().toISOString(), files };
fs.writeFileSync(path.join(dest, MANIFEST), JSON.stringify(manifest, null, 2), { flag: 'wx' });
return { dir: dest, name: path.basename(dest), files: files.length, from: saveDir };
}
function inspectBackup(root, name) {
if (typeof name !== 'string' || name === '.' || name === '..' || /[\\/:]/.test(name)) throw new Error('非法备份名');
const dir = path.join(root, name);
if (!fs.existsSync(dir) || fs.lstatSync(dir).isSymbolicLink() || !fs.statSync(dir).isDirectory()) throw new Error('备份不存在或是目录链接');
let manifest = null;
const meta = path.join(dir, MANIFEST);
if (fs.existsSync(meta)) {
manifest = JSON.parse(fs.readFileSync(meta, 'utf8'));
if (!Array.isArray(manifest.files) || !manifest.files.length) throw new Error('备份清单无效');
} else if (!/^\d{8}_\d{6}$|^[a-z][a-z0-9_-]*_\d{8}_\d{6}$/i.test(name)) {
throw new Error('备份没有完成标记');
}
const files = manifest ? manifest.files : saveNames(dir).map(name => ({ name }));
if (!files.length || new Set(files.map(f => f.name)).size !== files.length) throw new Error('备份为空或文件重复');
for (const f of files) {
const digest = hash(safeFile(dir, f.name));
if (f.sha256 && digest !== f.sha256) throw new Error('备份内容校验失败:' + f.name);
f.sha256 = digest;
}
return { dir, manifest, files };
}
function listBackups(root) {
if (!fs.existsSync(root)) return [];
return fs.readdirSync(root).flatMap(name => {
try {
const b = inspectBackup(root, name);
return [{ name, files: b.files.length, sourceDir: b.manifest?.sourceDir || null, legacy: !b.manifest, mtime: fs.statSync(b.dir).mtimeMs }];
} catch { return []; }
}).sort((a, b) => b.mtime - a.mtime);
}
function restoreBackup(root, name, targetDir) {
const b = inspectBackup(root, name);
const target = canonical(targetDir), saveDir = path.join(target, 'save');
if (fs.existsSync(saveDir) && fs.lstatSync(saveDir).isSymbolicLink()) throw new Error('不能还原到存档目录链接');
const safety = saveNames(saveDir).length ? backupSaves(root, target, 'prerestore') : null;
const stage = fs.mkdtempSync(path.join(target, '.save-restore-'));
// 保留云配置等非存档文件;存档集合严格来自选中的备份。
if (fs.existsSync(saveDir)) {
for (const entry of fs.readdirSync(saveDir, { withFileTypes: true })) {
if (entry.isSymbolicLink() || entry.isDirectory()) throw new Error('存档目录含链接或子目录,停止还原');
if (!/\.rmmzsave$/i.test(entry.name)) fs.copyFileSync(path.join(saveDir, entry.name), path.join(stage, entry.name));
}
}
for (const f of b.files) {
fs.copyFileSync(path.join(b.dir, f.name), path.join(stage, f.name));
if (hash(path.join(stage, f.name)) !== f.sha256) throw new Error('还原暂存校验失败');
}
const old = path.join(target, `.save-before-restore-${crypto.randomUUID()}`);
const hadSave = fs.existsSync(saveDir);
if (hadSave) fs.renameSync(saveDir, old);
try { fs.renameSync(stage, saveDir); }
catch (e) { if (hadSave) fs.renameSync(old, saveDir); throw e; }
return { restored: b.files.length, target: saveDir, safetyBackup: safety?.name || null, previousDirectory: hadSave ? old : null };
}
function excludedDirs(gameDir, toolDir) {
return fs.readdirSync(gameDir, { withFileTypes: true }).filter(e => e.isDirectory() && (
/^aitemp/i.test(e.name) || samePath(path.join(gameDir, e.name), toolDir) || fs.existsSync(path.join(gameDir, e.name, 'tool', 'server.js'))
)).map(e => path.join(gameDir, e.name));
}
function gameFiles(gameDir, excluded) {
const result = [];
function walk(dir, prefix = '') {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, e.name), rel = path.join(prefix, e.name);
if (!prefix && (['save', 'error', 'debug.log'].includes(e.name.toLowerCase()) || /^aitemp/i.test(e.name) || e.name.startsWith('.save-') || excluded.some(d => samePath(d, file)))) continue;
if (e.isSymbolicLink()) throw new Error('游戏目录含链接,无法验证完整副本:' + rel);
if (e.isDirectory()) walk(file, rel);
else if (e.isFile()) result.push({ name: rel, size: fs.statSync(file).size });
}
}
walk(gameDir); return result;
}
function sandboxStatus(gameDir, sandboxDir, toolDir, checkingCopy = false) {
try {
if (contains(gameDir, sandboxDir) || contains(sandboxDir, gameDir)) return { ready: false, reason: '沙盒路径与原版重叠' };
if (!fs.existsSync(sandboxDir)) return { ready: false, reason: '尚未创建' };
if (!checkingCopy && fs.existsSync(path.join(sandboxDir, INCOMPLETE))) return { ready: false, reason: '上次复制尚未完成,请另选空目录重新创建' };
const required = gameFiles(gameDir, excludedDirs(gameDir, toolDir));
if (!required.some(f => f.name === 'package.json') || !required.some(f => f.name === 'index.html')) throw new Error('原版游戏目录不完整');
const bad = required.find(f => {
const dest = path.join(sandboxDir, f.name);
return !fs.existsSync(dest) || !fs.statSync(dest).isFile() || fs.statSync(dest).size !== f.size || !contains(sandboxDir, canonical(dest));
});
if (bad) return { ready: false, reason: '副本缺失或大小不符:' + bad.name };
return { ready: true, legacy: !fs.existsSync(path.join(sandboxDir, COMPLETE)), files: required.length };
} catch (e) { return { ready: false, reason: e.message }; }
}
function copySandbox(gameDir, sandboxDir, toolDir) {
if (contains(gameDir, sandboxDir) || contains(sandboxDir, gameDir)) throw new Error('沙盒路径与原版重叠');
if (fs.existsSync(sandboxDir) && fs.readdirSync(sandboxDir).length) throw new Error('目标目录非空,不能覆盖;请选择新的空目录');
const excluded = excludedDirs(gameDir, toolDir);
const originalSave = path.join(gameDir, 'save');
const saves = saveNames(originalSave).map(name => ({ name, sha256: hash(safeFile(originalSave, name)) }));
if (!saves.length) throw new Error('原版没有存档,请先正常开始游戏并保存');
fs.mkdirSync(sandboxDir, { recursive: true });
fs.writeFileSync(path.join(sandboxDir, INCOMPLETE), new Date().toISOString(), { flag: 'wx' });
return new Promise((resolve, reject) => {
execFile('robocopy', [gameDir, sandboxDir, '/E', '/XJ', '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/R:1', '/W:1', '/XF', 'aitemp*', 'debug.log', '/XD', ...excluded, 'error', '.save-*'],
{ windowsHide: true, maxBuffer: 32 * 1024 * 1024 }, (err, out, stderr) => {
try {
if (err && !(Number.isInteger(err.code) && err.code <= 7 && err.code >= 0)) throw new Error(stderr || err.message);
const status = sandboxStatus(gameDir, sandboxDir, toolDir, true);
if (!status.ready) throw new Error(status.reason);
if (JSON.stringify(saveNames(originalSave)) !== JSON.stringify(saves.map(f => f.name)) || saves.some(f => hash(safeFile(originalSave, f.name)) !== f.sha256 || hash(safeFile(path.join(sandboxDir, 'save'), f.name)) !== f.sha256)) throw new Error('复制期间存档发生变化或副本校验失败,请退出游戏后重建');
fs.writeFileSync(path.join(sandboxDir, COMPLETE), JSON.stringify({ createdAt: new Date().toISOString(), files: status.files }));
fs.unlinkSync(path.join(sandboxDir, INCOMPLETE));
resolve(status);
} catch (e) { reject(e); }
});
});
}
module.exports = { backupSaves, listBackups, restoreBackup, inspectBackup, sandboxStatus, copySandbox, excludedDirs };