diff --git a/src/main/character-dictionary-runtime/zip.test.ts b/src/main/character-dictionary-runtime/zip.test.ts index 2316a0ee..6b2f73b1 100644 --- a/src/main/character-dictionary-runtime/zip.test.ts +++ b/src/main/character-dictionary-runtime/zip.test.ts @@ -129,10 +129,20 @@ test('readDictionaryZipRevision reads the built revision and rejects foreign arc 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, fs.readFileSync(zipPath).subarray(0, 40)); + 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`); + } + const foreignPath = path.join(dir, 'foreign.zip'); fs.writeFileSync(foreignPath, Buffer.from('not a zip at all', 'utf8')); assert.equal(readDictionaryZipRevision(foreignPath), null); diff --git a/src/shared/stored-zip.ts b/src/shared/stored-zip.ts index 90d66eec..9323a007 100644 --- a/src/shared/stored-zip.ts +++ b/src/shared/stored-zip.ts @@ -150,12 +150,43 @@ function createEndOfCentralDirectory( const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; const LOCAL_FILE_HEADER_SIZE = 30; +const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; +const END_OF_CENTRAL_DIRECTORY_SIZE = 22; + +/** + * Start of the central directory, or null when the archive is not a complete one of ours. The + * end-of-central-directory record is written last, so finding an intact one is what separates a + * finished archive from a half-written one. + */ +function readCentralDirectoryStart(fd: number, fileSize: number): number | null { + if (fileSize < END_OF_CENTRAL_DIRECTORY_SIZE) { + return null; + } + const end = Buffer.alloc(END_OF_CENTRAL_DIRECTORY_SIZE); + const endOffset = fileSize - END_OF_CENTRAL_DIRECTORY_SIZE; + if (fs.readSync(fd, end, 0, end.length, endOffset) !== end.length) { + return null; + } + // writeStoredZip never writes an archive comment, so the record is exactly the last 22 bytes. + if (end.readUInt32LE(0) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE || end.readUInt16LE(20) !== 0) { + return null; + } + if (end.readUInt16LE(10) === 0) { + return null; + } + const centralSize = end.readUInt32LE(12); + const centralStart = end.readUInt32LE(16); + if (centralStart + centralSize !== endOffset) { + return null; + } + return centralStart; +} /** * Reads the first entry of an archive written by {@link writeStoredZip}: every entry is stored * uncompressed with no extra field and no data descriptor, so the leading local header is enough. - * Returns null for anything it does not recognize, so callers treat a corrupt or foreign archive - * the same as a missing one. + * Returns null for anything it does not recognize, so callers treat a corrupt, truncated, or + * foreign archive the same as a missing one. */ export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null { let fd: number; @@ -167,6 +198,10 @@ export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null { try { const fileSize = fs.fstatSync(fd).size; + const centralStart = readCentralDirectoryStart(fd, fileSize); + if (centralStart === null) { + return null; + } const header = Buffer.alloc(LOCAL_FILE_HEADER_SIZE); if (fs.readSync(fd, header, 0, header.length, 0) !== header.length) { return null; @@ -182,7 +217,7 @@ export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null { const nameLength = header.readUInt16LE(26); const extraLength = header.readUInt16LE(28); const dataOffset = LOCAL_FILE_HEADER_SIZE + nameLength + extraLength; - if (dataOffset + entrySize > fileSize) { + if (dataOffset + entrySize > centralStart) { return null; } const name = Buffer.alloc(nameLength);