fix(dictionary): reject zips with missing/truncated central directory

- readStoredZipFirstFile now validates the end-of-central-directory record before trusting a local file header, catching archives cut short after index.json
- add test cases for archives truncated by 1 byte, by the full EOCD record, and near the end of the central directory
This commit is contained in:
2026-08-10 22:42:27 -07:00
parent 11b6f84d41
commit 8a56b11d0c
2 changed files with 49 additions and 4 deletions
@@ -129,10 +129,20 @@ test('readDictionaryZipRevision reads the built revision and rejects foreign arc
assert.equal(readDictionaryZipRevision(zipPath), 'rev-42'); assert.equal(readDictionaryZipRevision(zipPath), 'rev-42');
assert.equal(readDictionaryZipRevision(path.join(dir, 'missing.zip')), null); assert.equal(readDictionaryZipRevision(path.join(dir, 'missing.zip')), null);
const archive = fs.readFileSync(zipPath);
const truncatedPath = path.join(dir, 'truncated.zip'); 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); 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'); const foreignPath = path.join(dir, 'foreign.zip');
fs.writeFileSync(foreignPath, Buffer.from('not a zip at all', 'utf8')); fs.writeFileSync(foreignPath, Buffer.from('not a zip at all', 'utf8'));
assert.equal(readDictionaryZipRevision(foreignPath), null); assert.equal(readDictionaryZipRevision(foreignPath), null);
+38 -3
View File
@@ -150,12 +150,43 @@ function createEndOfCentralDirectory(
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const LOCAL_FILE_HEADER_SIZE = 30; 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 * 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. * 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 * Returns null for anything it does not recognize, so callers treat a corrupt, truncated, or
* the same as a missing one. * foreign archive the same as a missing one.
*/ */
export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null { export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null {
let fd: number; let fd: number;
@@ -167,6 +198,10 @@ export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null {
try { try {
const fileSize = fs.fstatSync(fd).size; const fileSize = fs.fstatSync(fd).size;
const centralStart = readCentralDirectoryStart(fd, fileSize);
if (centralStart === null) {
return null;
}
const header = Buffer.alloc(LOCAL_FILE_HEADER_SIZE); const header = Buffer.alloc(LOCAL_FILE_HEADER_SIZE);
if (fs.readSync(fd, header, 0, header.length, 0) !== header.length) { if (fs.readSync(fd, header, 0, header.length, 0) !== header.length) {
return null; return null;
@@ -182,7 +217,7 @@ export function readStoredZipFirstFile(zipPath: string): StoredZipFile | null {
const nameLength = header.readUInt16LE(26); const nameLength = header.readUInt16LE(26);
const extraLength = header.readUInt16LE(28); const extraLength = header.readUInt16LE(28);
const dataOffset = LOCAL_FILE_HEADER_SIZE + nameLength + extraLength; const dataOffset = LOCAL_FILE_HEADER_SIZE + nameLength + extraLength;
if (dataOffset + entrySize > fileSize) { if (dataOffset + entrySize > centralStart) {
return null; return null;
} }
const name = Buffer.alloc(nameLength); const name = Buffer.alloc(nameLength);