mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user