7z keeps its file list at the end of the archive, so the 7z-tail candidates (the last segment of the highest volume) were queued but never probed, and the .7z.001 start only gave the type. The prober now claims 7z-tail candidates after the archives; lib/sevenzip finds the end header in the article, decodes the LZMA-packed header (a TypeScript port of the LZMA SDK's reference decoder) and lists the files with their sizes. An AES-encrypted header means passworded without names. Names feed the inner name and the judge's fake rule. An unsplit .7z of several segments now also gets a tail candidate, and .zip files an archive candidate: their first segment's local headers give the first entries and whether they are encrypted. Both in the Rust edge and the TypeScript indexer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
51 lines
2.3 KiB
TypeScript
51 lines
2.3 KiB
TypeScript
/**
|
|
* The entries a ZIP's start shows: its local file headers, one before each entry's data, as far as the data
|
|
* holds them (the first segment of a .zip; REBUILD.md, phase 2). The full list is in the central directory at
|
|
* the end; the start names the first entries and says whether they're encrypted, as a RAR's first volume does.
|
|
*/
|
|
export interface ZipStart {
|
|
files: { name: string; size: number }[]
|
|
encrypted: boolean
|
|
}
|
|
|
|
const LOCAL = 0x04034b50
|
|
/** Traditional PKWARE encryption (bit 0 of the flags), or WinZip AES (method 99). */
|
|
const ENCRYPTED_FLAG = 0x1
|
|
const DATA_DESCRIPTOR_FLAG = 0x8
|
|
const AES_METHOD = 99
|
|
const MAX_ENTRIES = 10_000
|
|
|
|
export function parseZipStart(data: Buffer): ZipStart | null {
|
|
if (data.length < 30 || data.readUInt32LE(0) !== LOCAL) return null
|
|
const result: ZipStart = { files: [], encrypted: false }
|
|
let pos = 0
|
|
while (pos + 30 <= data.length && data.readUInt32LE(pos) === LOCAL && result.files.length < MAX_ENTRIES) {
|
|
const flags = data.readUInt16LE(pos + 6)
|
|
const method = data.readUInt16LE(pos + 8)
|
|
let compressed = data.readUInt32LE(pos + 18)
|
|
let size = data.readUInt32LE(pos + 22)
|
|
const nameLength = data.readUInt16LE(pos + 26)
|
|
const extraLength = data.readUInt16LE(pos + 28)
|
|
const nameEnd = pos + 30 + nameLength
|
|
if (nameEnd > data.length) break
|
|
// Bit 11: UTF-8 names; others are mostly CP437, read as latin1.
|
|
const name = data.toString(flags & 0x800 ? 'utf8' : 'latin1', pos + 30, nameEnd)
|
|
// Zip64: the real sizes in extra field 0x0001.
|
|
for (let e = nameEnd; e + 4 <= Math.min(nameEnd + extraLength, data.length); ) {
|
|
const id = data.readUInt16LE(e)
|
|
const length = data.readUInt16LE(e + 2)
|
|
if (id === 0x0001 && e + 4 + length <= data.length && length >= 16) {
|
|
size = Number(data.readBigUInt64LE(e + 4))
|
|
compressed = Number(data.readBigUInt64LE(e + 12))
|
|
}
|
|
e += 4 + length
|
|
}
|
|
if (flags & ENCRYPTED_FLAG || method === AES_METHOD) result.encrypted = true
|
|
if (!name.endsWith('/')) result.files.push({ name, size })
|
|
// Sizes after the data (a streamed zip): where the next header starts isn't known.
|
|
if (flags & DATA_DESCRIPTOR_FLAG) break
|
|
pos = nameEnd + extraLength + compressed
|
|
}
|
|
return result.files.length > 0 || result.encrypted ? result : null
|
|
}
|