Files
core2026/tools/legacy-db-migration/src/battleResultSource.ts
T
Hide_D c04a93a76c feat: 과거 장수 전투 결과 보존 이관 추가
preserved batres 파일을 기수 단위로 검증·체크포인트하고 과거 장수 상세에 연결한다. check-plan에는 전체 이전 항목과 정보 설명을 함께 노출한다.
2026-08-18 13:15:33 +00:00

320 lines
13 KiB
TypeScript

import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
import { TextDecoder } from 'node:util';
import type { LegacyArchiveProfile } from './game.js';
import type { MigrationSourceIdentity } from './incremental.js';
const MAX_BATTLE_RESULT_FILE_BYTES = 4 * 1024 * 1024;
const SSH_HOST = /^(?:[a-zA-Z0-9._-]+@)?[a-zA-Z0-9._-]+$/u;
const HASH = /^[a-f0-9]{64}$/u;
export interface BattleResultSourceConfig {
kind: 'local' | 'ssh';
directory: string;
sshHost?: string;
identity: MigrationSourceIdentity;
}
export interface BattleResultFileDescriptor {
serverId: string;
generalNo: number;
sourceBytes: number;
contentHash: string;
}
export interface BattleResultSeasonManifest {
serverId: string;
files: BattleResultFileDescriptor[];
fileCount: number;
totalBytes: number;
manifestHash: string;
}
export interface BattleResultFile extends BattleResultFileDescriptor {
content: string;
lineCount: number;
}
type RemoteDescriptor = {
serverId: string;
generalNo: number;
sourceBytes: number;
contentHash: string;
contentBase64?: string;
};
const REMOTE_READER = String.raw`
import base64, hashlib, json, os, re, sys
MAX_BYTES = 4 * 1024 * 1024
action, root_input, profile = sys.argv[1:4]
selected = set(json.loads(base64.urlsafe_b64decode(sys.argv[4]).decode('utf-8'))) if len(sys.argv) > 4 else set()
root = os.path.realpath(root_input)
season_re = re.compile(r'^' + re.escape(profile) + r'_[A-Za-z0-9_-]{1,96}$')
file_re = re.compile(r'^batres([0-9]+)\.txt$')
if not os.path.isdir(root):
raise RuntimeError('preserved battle-result directory is not readable')
for season in sorted(os.scandir(root), key=lambda item: item.name):
if not season.is_dir(follow_symlinks=False) or not season_re.fullmatch(season.name):
continue
if action == 'read' and season.name not in selected:
continue
for item in sorted(os.scandir(season.path), key=lambda entry: entry.name):
match = file_re.fullmatch(item.name)
if not match or not item.is_file(follow_symlinks=False):
continue
size = item.stat(follow_symlinks=False).st_size
if size > MAX_BYTES:
raise RuntimeError(f'battle-result file exceeds {MAX_BYTES} bytes: {season.name}/{item.name}')
digest = hashlib.sha256()
content = bytearray()
with open(item.path, 'rb') as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
content.extend(chunk)
content.decode('utf-8')
if b'\0' in content:
raise RuntimeError(f'battle-result file contains NUL: {season.name}/{item.name}')
result = {
'serverId': season.name,
'generalNo': int(match.group(1)),
'sourceBytes': size,
'contentHash': digest.hexdigest(),
}
if action == 'read':
result['contentBase64'] = base64.b64encode(content).decode('ascii')
print(json.dumps(result, ensure_ascii=True), flush=True)
`;
const safeSourceDirectory = (directory: string): string => {
if (!path.isAbsolute(directory) || directory.length > 4096 || /[\0\r\n]/u.test(directory)) {
throw new Error('Preserved battle-result directory must be a safe absolute path');
}
return directory;
};
export const resolveBattleResultSourceConfig = async (
input: { directory: string; sshHost?: string },
sourceKey: string,
profile: LegacyArchiveProfile,
configDirectory: string
): Promise<BattleResultSourceConfig> => {
const sshHost = input.sshHost?.trim();
let directory: string;
let kind: BattleResultSourceConfig['kind'];
if (sshHost) {
if (!SSH_HOST.test(sshHost) || sshHost.startsWith('-')) {
throw new Error('battleResults.sshHost must be a safe SSH host or configured alias');
}
directory = safeSourceDirectory(input.directory.trim());
kind = 'ssh';
} else {
directory = await realpath(path.resolve(configDirectory, input.directory));
safeSourceDirectory(directory);
const info = await lstat(directory);
if (!info.isDirectory()) throw new Error('battleResults.directory must be a directory');
kind = 'local';
}
const fingerprint = createHash('sha256')
.update(JSON.stringify({ kind, directory, sshHost: sshHost ?? null, profile }))
.digest('hex');
return {
kind,
directory,
...(sshHost ? { sshHost } : {}),
identity: { key: `${sourceKey}:battle-results`, fingerprint },
};
};
const localDescriptors = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
selected?: ReadonlySet<string>
): Promise<RemoteDescriptor[]> => {
const seasonPattern = new RegExp(`^${profile}_[A-Za-z0-9_-]{1,96}$`, 'u');
const filePattern = /^batres([0-9]+)\.txt$/u;
const result: RemoteDescriptor[] = [];
for (const season of (await readdir(source.directory, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
)) {
if (!season.isDirectory() || !seasonPattern.test(season.name) || (selected && !selected.has(season.name))) {
continue;
}
const seasonPath = path.join(source.directory, season.name);
for (const file of (await readdir(seasonPath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
)) {
const match = filePattern.exec(file.name);
if (!match || !file.isFile()) continue;
const filePath = path.join(seasonPath, file.name);
const info = await lstat(filePath);
if (!info.isFile() || info.isSymbolicLink()) continue;
if (info.size > MAX_BATTLE_RESULT_FILE_BYTES) {
throw new Error(
`Battle-result file exceeds ${MAX_BATTLE_RESULT_FILE_BYTES} bytes: ${season.name}/${file.name}`
);
}
const content = await readFile(filePath);
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(content);
if (decoded.includes('\0')) {
throw new Error(`Battle-result file contains NUL: ${season.name}/${file.name}`);
}
result.push({
serverId: season.name,
generalNo: Number(match[1]),
sourceBytes: info.size,
contentHash: createHash('sha256').update(content).digest('hex'),
...(selected ? { contentBase64: content.toString('base64') } : {}),
});
}
}
return result;
};
const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`;
const remoteDescriptors = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
selected?: ReadonlySet<string>
): Promise<RemoteDescriptor[]> => {
if (!source.sshHost) throw new Error('SSH battle-result source is missing its host');
const action = selected ? 'read' : 'list';
const encodedSelection = Buffer.from(JSON.stringify([...(selected ?? [])]), 'utf8').toString('base64');
const remoteCommand = ['python3', '-c', REMOTE_READER, action, source.directory, profile, encodedSelection]
.map(shellQuote)
.join(' ');
const child = spawn('ssh', ['-C', '--', source.sshHost, remoteCommand], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => {
if (stderr.length < 8192) stderr += chunk.slice(0, 8192 - stderr.length);
});
const exit = new Promise<number>((resolve, reject) => {
child.once('error', reject);
child.once('close', (code) => resolve(code ?? 1));
});
const output: RemoteDescriptor[] = [];
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
try {
for await (const line of lines) {
if (!line.trim()) continue;
const parsed = JSON.parse(line) as RemoteDescriptor;
output.push(parsed);
}
} catch (error) {
child.kill();
throw new Error('Could not parse the preserved battle-result SSH stream', { cause: error });
}
const code = await exit;
if (code !== 0) {
throw new Error(`Preserved battle-result SSH scan failed (${code}): ${stderr.trim() || 'no error text'}`);
}
return output;
};
const descriptors = (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
selected?: ReadonlySet<string>
): Promise<RemoteDescriptor[]> =>
source.kind === 'local'
? localDescriptors(source, profile, selected)
: remoteDescriptors(source, profile, selected);
const validateDescriptor = (descriptor: RemoteDescriptor, profile: LegacyArchiveProfile): void => {
if (!new RegExp(`^${profile}_[A-Za-z0-9_-]{1,96}$`, 'u').test(descriptor.serverId)) {
throw new Error(`Invalid battle-result server ID: ${descriptor.serverId}`);
}
if (!Number.isSafeInteger(descriptor.generalNo) || descriptor.generalNo < 0) {
throw new Error(`Invalid battle-result general number for ${descriptor.serverId}`);
}
if (
!Number.isSafeInteger(descriptor.sourceBytes) ||
descriptor.sourceBytes < 0 ||
descriptor.sourceBytes > MAX_BATTLE_RESULT_FILE_BYTES ||
!HASH.test(descriptor.contentHash)
) {
throw new Error(`Invalid battle-result descriptor for ${descriptor.serverId}/${descriptor.generalNo}`);
}
};
const manifestFor = (serverId: string, files: BattleResultFileDescriptor[]): BattleResultSeasonManifest => {
files.sort((left, right) => left.generalNo - right.generalNo);
const manifest = createHash('sha256');
for (const file of files) {
manifest.update(`${file.generalNo}\0${file.sourceBytes}\0${file.contentHash}\n`);
}
return {
serverId,
files,
fileCount: files.length,
totalBytes: files.reduce((sum, file) => sum + file.sourceBytes, 0),
manifestHash: manifest.digest('hex'),
};
};
export const listBattleResultSeasons = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile
): Promise<BattleResultSeasonManifest[]> => {
const grouped = new Map<string, BattleResultFileDescriptor[]>();
for (const descriptor of await descriptors(source, profile)) {
validateDescriptor(descriptor, profile);
const files = grouped.get(descriptor.serverId) ?? [];
if (files.some((file) => file.generalNo === descriptor.generalNo)) {
throw new Error(`Duplicate battle-result file for ${descriptor.serverId}/${descriptor.generalNo}`);
}
files.push(descriptor);
grouped.set(descriptor.serverId, files);
}
return [...grouped.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([serverId, files]) => manifestFor(serverId, files));
};
export const readBattleResultSeason = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
serverId: string
): Promise<{ manifest: BattleResultSeasonManifest; files: BattleResultFile[] }> => {
const files: BattleResultFile[] = [];
const decoder = new TextDecoder('utf-8', { fatal: true });
for (const descriptor of await descriptors(source, profile, new Set([serverId]))) {
validateDescriptor(descriptor, profile);
if (descriptor.serverId !== serverId || typeof descriptor.contentBase64 !== 'string') {
throw new Error(`Unexpected battle-result file while reading ${serverId}`);
}
const bytes = Buffer.from(descriptor.contentBase64, 'base64');
if (
bytes.byteLength !== descriptor.sourceBytes ||
createHash('sha256').update(bytes).digest('hex') !== descriptor.contentHash
) {
throw new Error(`Battle-result content changed while reading ${serverId}/${descriptor.generalNo}`);
}
const content = decoder.decode(bytes);
if (content.includes('\0'))
throw new Error(`Battle-result file contains NUL: ${serverId}/${descriptor.generalNo}`);
files.push({
serverId,
generalNo: descriptor.generalNo,
sourceBytes: descriptor.sourceBytes,
contentHash: descriptor.contentHash,
content,
lineCount: content.split(/\r?\n/u).filter((line) => line.length > 0).length,
});
}
return { manifest: manifestFor(serverId, files), files };
};