mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 01:55:58 -07:00
168 lines
6.2 KiB
TypeScript
168 lines
6.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { buildDictionaryZip, readDictionaryZipRevision } from './zip';
|
|
import type { CharacterDictionaryTermEntry } from './types';
|
|
|
|
function makeTempDir(): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-zip-'));
|
|
}
|
|
|
|
function cleanupDir(dirPath: string): void {
|
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
}
|
|
|
|
function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
|
|
const archive = fs.readFileSync(zipPath);
|
|
const entries = new Map<string, Buffer>();
|
|
let cursor = 0;
|
|
|
|
while (cursor + 4 <= archive.length) {
|
|
const signature = archive.readUInt32LE(cursor);
|
|
if (signature === 0x02014b50 || signature === 0x06054b50) {
|
|
break;
|
|
}
|
|
assert.equal(signature, 0x04034b50, `unexpected local file header at offset ${cursor}`);
|
|
|
|
const compressedSize = archive.readUInt32LE(cursor + 18);
|
|
const fileNameLength = archive.readUInt16LE(cursor + 26);
|
|
const extraLength = archive.readUInt16LE(cursor + 28);
|
|
const fileNameStart = cursor + 30;
|
|
const dataStart = fileNameStart + fileNameLength + extraLength;
|
|
const fileName = archive
|
|
.subarray(fileNameStart, fileNameStart + fileNameLength)
|
|
.toString('utf8');
|
|
const data = archive.subarray(dataStart, dataStart + compressedSize);
|
|
entries.set(fileName, Buffer.from(data));
|
|
cursor = dataStart + compressedSize;
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', () => {
|
|
const tempDir = makeTempDir();
|
|
const outputPath = path.join(tempDir, 'dictionary.zip');
|
|
const termEntries: CharacterDictionaryTermEntry[] = [
|
|
['アルファ', 'あるふぁ', '', '', 0, ['Alpha entry'], 0, 'name'],
|
|
];
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
const originalBufferConcat = Buffer.concat;
|
|
|
|
try {
|
|
fs.writeFileSync = ((..._args: unknown[]) => {
|
|
throw new Error('buildDictionaryZip should not call fs.writeFileSync');
|
|
}) as typeof fs.writeFileSync;
|
|
|
|
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
|
|
throw new Error(
|
|
`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`,
|
|
);
|
|
}) as typeof Buffer.concat;
|
|
|
|
const result = buildDictionaryZip(
|
|
outputPath,
|
|
'Dictionary Title',
|
|
'Dictionary Description',
|
|
'2026-03-27',
|
|
termEntries,
|
|
[{ path: 'images/alpha.bin', dataBase64: Buffer.from([1, 2, 3]).toString('base64') }],
|
|
);
|
|
|
|
assert.equal(result.zipPath, outputPath);
|
|
assert.equal(result.entryCount, 1);
|
|
|
|
const entries = readStoredZipEntries(outputPath);
|
|
assert.deepEqual([...entries.keys()].sort(), [
|
|
'images/alpha.bin',
|
|
'index.json',
|
|
'tag_bank_1.json',
|
|
'term_bank_1.json',
|
|
]);
|
|
|
|
const indexJson = JSON.parse(entries.get('index.json')!.toString('utf8')) as {
|
|
title: string;
|
|
description: string;
|
|
revision: string;
|
|
format: number;
|
|
};
|
|
assert.equal(indexJson.title, 'Dictionary Title');
|
|
assert.equal(indexJson.description, 'Dictionary Description');
|
|
assert.equal(indexJson.revision, '2026-03-27');
|
|
assert.equal(indexJson.format, 3);
|
|
|
|
const termBank = JSON.parse(
|
|
entries.get('term_bank_1.json')!.toString('utf8'),
|
|
) as CharacterDictionaryTermEntry[];
|
|
assert.equal(termBank.length, 1);
|
|
assert.equal(termBank[0]?.[0], 'アルファ');
|
|
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
|
} finally {
|
|
fs.writeFileSync = originalWriteFileSync;
|
|
Buffer.concat = originalBufferConcat;
|
|
cleanupDir(tempDir);
|
|
}
|
|
});
|
|
|
|
test('readDictionaryZipRevision reads the built revision and rejects foreign archives', () => {
|
|
const dir = makeTempDir();
|
|
try {
|
|
const zipPath = path.join(dir, 'merged.zip');
|
|
buildDictionaryZip(
|
|
zipPath,
|
|
'SubMiner Character Dictionary',
|
|
'Character names',
|
|
'rev-42',
|
|
[
|
|
{
|
|
term: 'ルフィ',
|
|
reading: 'ルフィ',
|
|
role: 'main',
|
|
glossary: [],
|
|
} as unknown as CharacterDictionaryTermEntry,
|
|
],
|
|
[],
|
|
);
|
|
|
|
assert.equal(readDictionaryZipRevision(zipPath), 'rev-42');
|
|
assert.equal(readDictionaryZipRevision(path.join(dir, 'missing.zip')), null);
|
|
|
|
const archive = fs.readFileSync(zipPath);
|
|
const truncatedPath = path.join(dir, 'truncated.zip');
|
|
fs.writeFileSync(truncatedPath, archive.subarray(0, 40));
|
|
assert.equal(readDictionaryZipRevision(truncatedPath), null);
|
|
|
|
// An archive cut short after index.json still holds a readable revision, but importing it
|
|
// would hand Yomitan a half-written file: the missing end-of-central-directory record has to
|
|
// reject it. One byte off the end is enough to make the record incomplete.
|
|
for (const missingBytes of [1, 22, archive.length - 200]) {
|
|
const cutPath = path.join(dir, `cut-${missingBytes}.zip`);
|
|
fs.writeFileSync(cutPath, archive.subarray(0, archive.length - missingBytes));
|
|
assert.equal(readDictionaryZipRevision(cutPath), null, `cut of ${missingBytes} bytes`);
|
|
}
|
|
|
|
// Same size, corrupt directory: a record overwritten in place has to be rejected too.
|
|
const centralStart = archive.readUInt32LE(archive.length - 22 + 16);
|
|
const brokenSignaturePath = path.join(dir, 'broken-signature.zip');
|
|
const brokenSignature = Buffer.from(archive);
|
|
brokenSignature.writeUInt32LE(0xdeadbeef, centralStart);
|
|
fs.writeFileSync(brokenSignaturePath, brokenSignature);
|
|
assert.equal(readDictionaryZipRevision(brokenSignaturePath), null);
|
|
|
|
const brokenLengthPath = path.join(dir, 'broken-length.zip');
|
|
const brokenLength = Buffer.from(archive);
|
|
// Name length that runs the walk past the end of the directory.
|
|
brokenLength.writeUInt16LE(0xffff, centralStart + 28);
|
|
fs.writeFileSync(brokenLengthPath, brokenLength);
|
|
assert.equal(readDictionaryZipRevision(brokenLengthPath), null);
|
|
|
|
const foreignPath = path.join(dir, 'foreign.zip');
|
|
fs.writeFileSync(foreignPath, Buffer.from('not a zip at all', 'utf8'));
|
|
assert.equal(readDictionaryZipRevision(foreignPath), null);
|
|
} finally {
|
|
cleanupDir(dir);
|
|
}
|
|
});
|