Probe 7z listings and ZIP starts

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>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-23 11:55:07 +02:00
1 parent 3b61215f6b
commit d446bb57df
25 files changed
+4840 -11

No files matched your search

+2
View File
@@ -434,6 +434,8 @@ export function candidatesOf(key: string, files: AggregatedFile<EdgeSegment>[],
if (!tail || volume > tail.volume) tail = { volume, file: f }
continue
}
// An unsplit 7z of more than one segment: its listing is in its last one.
if (volume === 0 && f.segments.length > 1 && !tail) tail = { volume, file: f }
if (isArchiveStart(name)) out.push({ releaseKey: key, upload, kind: 'archive', messageId: f.segments[0].messageId, file: name })
else if (/\.par2$/i.test(name) && !isLaterVolume(name)) out.push({ releaseKey: key, upload, kind: 'par2', messageId: f.segments[0].messageId, file: name })
}
+3 -2
View File
@@ -1,10 +1,11 @@
/**
* Files whose first segment says what an archive holds: named first RAR
* volumes (.part01.rar, .rar), 7z (.7z, .7z.001) and `.bin`, an extension
* volumes (.part01.rar, .rar), 7z (.7z, .7z.001: its type; the listing is at
* its end, the 7z-tail), ZIP (its first entries) and `.bin`, an extension
* uploaders use for archive volumes too (sniffed by content).
*/
export function isArchiveStart(name: string): boolean {
if (/\.bin$/i.test(name)) return true
if (/\.(bin|zip)$/i.test(name)) return true
const volume = sevenZipVolume(name)
if (volume === 0 || volume === 1) return true
return /\.part0*1\.rar$/i.test(name) || (/\.rar$/i.test(name) && !/\.part\d+\.rar$/i.test(name))
+25 -3
View File
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto'
import { decodeSegment, YencCrcError, YencError, type DecodedSegment } from '#lib/yenc/index'
import { isPar2, parsePar2 } from '#lib/par2/index'
import { parseRarHeaders } from '#lib/rar/index'
import { parseSevenZipTail } from '#lib/sevenzip/index'
import { parseZipStart } from '#lib/zip/index'
import { sniffContentType, type ContentType } from '#lib/content/index'
/** What one probed article showed (REBUILD.md, part 6; stored per release and kind). */
@@ -55,8 +57,11 @@ function largestFirst(items: { name: string; size: number }[]): string[] {
.map((i) => i.name.slice(0, MAX_NAME_LENGTH))
}
/** One article's body: its yEnc header, what the first bytes are, and for par2 and RAR what's inside. Throws when it isn't yEnc. */
export function inspect(body: Buffer): Inspection {
/**
* One article's body: its yEnc header, what the first bytes are, and for par2, RAR, ZIP and 7z what's inside.
* Throws when it isn't yEnc. `kind` is the probe candidate's: a 7z-tail's article ends the archive.
*/
export function inspect(body: Buffer, kind?: string): Inspection {
const segment = decodeBody(body)
const result: Inspection = { yencName: segment.meta.begin.name.slice(0, MAX_NAME_LENGTH), size: segment.meta.begin.size, type: sniffContentType(segment.data) }
// The file's first 16 KiB, or all of a smaller file.
@@ -85,6 +90,23 @@ export function inspect(body: Buffer): Inspection {
result.passworded = info.headersEncrypted || files.some((e) => e.encrypted)
}
}
if (result.type === 'zip') {
const start = parseZipStart(segment.data)
if (start) {
result.entries = start.files.length
result.names = largestFirst(start.files)
result.passworded = start.encrypted
}
}
// 7z keeps its listing at the end: the last segment of the last volume (a 7z-tail), or a small archive whole.
if ((kind === '7z-tail' || result.type === '7z') && segment.offset + segment.data.length === result.size) {
const listing = parseSevenZipTail(segment.data)
if (listing) {
result.entries = listing.files.length
if (listing.files.length > 0) result.names = largestFirst(listing.files)
result.passworded = listing.encrypted
}
}
return result
}
@@ -97,7 +119,7 @@ export function inspect(body: Buffer): Inspection {
export function innerName(inspections: { kind: string; result: Inspection }[], releaseName = ''): string | null {
const of = (kind: string) => inspections.filter((i) => i.kind === kind).map((i) => i.result)
const content = (kind: string) => of(kind).flatMap((i) => i.names ?? []).find((n) => !PACKAGING.test(n))
const pick = content('archive') ?? content('par2') ?? of('lone').find((i) => i.yencName && !PACKAGING.test(i.yencName))?.yencName
const pick = content('archive') ?? content('7z-tail') ?? content('par2') ?? of('lone').find((i) => i.yencName && !PACKAGING.test(i.yencName))?.yencName
const name = pick?.replace(/^.*[\\/]/, '').slice(0, 255)
// The release's own name with an extension says nothing new (a random-named file's yEnc name is mostly its random name).
return name && !name.toLowerCase().startsWith(releaseName.toLowerCase() || '\0') ? name : null
+2
View File
@@ -0,0 +1,2 @@
export { parseSevenZipTail, type SevenZipListing } from '#lib/sevenzip/parse'
export { lzmaDecode, LzmaError } from '#lib/sevenzip/lzma'
+221
View File
@@ -0,0 +1,221 @@
/**
* An LZMA decoder (raw stream, as a 7z coder stores it: the properties apart, the unpacked size known), after the
* LZMA SDK's reference decoder (LzmaSpec.cpp). For 7z headers: those are small, so the whole output is one buffer.
*/
const MODEL_BITS = 11
const BIT_MODEL_TOTAL = 1 << MODEL_BITS
const MOVE_BITS = 5
const TOP = 1 << 24
const POS_BITS_MAX = 4
const STATES = 12
const LEN_TO_POS_STATES = 4
const ALIGN_BITS = 4
const END_POS_MODEL_INDEX = 14
const FULL_DISTANCES = 1 << (END_POS_MODEL_INDEX >> 1)
const MATCH_MIN_LEN = 2
export class LzmaError extends Error {}
const probs = (n: number) => new Uint16Array(n).fill(BIT_MODEL_TOTAL >>> 1)
class RangeDecoder {
private range = 0xffffffff
private code = 0
constructor(
private input: Uint8Array,
private pos: number
) {
if (this.byte() !== 0) throw new LzmaError('LZMA: bad stream start')
for (let i = 0; i < 4; i++) this.code = ((this.code << 8) | this.byte()) >>> 0
if (this.code === this.range) throw new LzmaError('LZMA: corrupt stream')
}
private byte(): number {
if (this.pos >= this.input.length) throw new LzmaError('LZMA: input ended')
return this.input[this.pos++]
}
private normalize() {
if (this.range < TOP) {
this.range = (this.range << 8) >>> 0
this.code = ((this.code << 8) | this.byte()) >>> 0
}
}
direct(bits: number): number {
let result = 0
do {
this.range >>>= 1
this.code = (this.code - this.range) >>> 0
const t = this.code >>> 31 ? 0xffffffff : 0
this.code = (this.code + (this.range & t)) >>> 0
this.normalize()
result = ((result << 1) | (t ? 0 : 1)) >>> 0
} while (--bits)
return result
}
bit(p: Uint16Array, i: number): number {
const v = p[i]
const bound = ((this.range >>> MODEL_BITS) * v) >>> 0
let symbol: number
if (this.code < bound) {
p[i] = v + ((BIT_MODEL_TOTAL - v) >>> MOVE_BITS)
this.range = bound
symbol = 0
} else {
p[i] = v - (v >>> MOVE_BITS)
this.code = (this.code - bound) >>> 0
this.range = (this.range - bound) >>> 0
symbol = 1
}
this.normalize()
return symbol
}
tree(p: Uint16Array, offset: number, bits: number): number {
let m = 1
for (let i = 0; i < bits; i++) m = (m << 1) + this.bit(p, offset + m)
return m - (1 << bits)
}
reverseTree(p: Uint16Array, offset: number, bits: number): number {
let m = 1
let symbol = 0
for (let i = 0; i < bits; i++) {
const bit = this.bit(p, offset + m)
m = (m << 1) + bit
symbol |= bit << i
}
return symbol
}
}
class LenDecoder {
private choice = probs(2)
private low = probs((1 << POS_BITS_MAX) << 3)
private mid = probs((1 << POS_BITS_MAX) << 3)
private high = probs(1 << 8)
decode(rc: RangeDecoder, posState: number): number {
if (rc.bit(this.choice, 0) === 0) return rc.tree(this.low, posState << 3, 3)
if (rc.bit(this.choice, 1) === 0) return 8 + rc.tree(this.mid, posState << 3, 3)
return 16 + rc.tree(this.high, 0, 8)
}
}
/** Decodes `size` bytes of a raw LZMA stream with its 5 property bytes (lc/lp/pb, dictionary size). */
export function lzmaDecode(properties: Uint8Array, input: Uint8Array, size: number): Buffer {
if (properties.length < 5) throw new LzmaError('LZMA: short properties')
let d = properties[0]
if (d >= 9 * 5 * 5) throw new LzmaError('LZMA: bad properties')
const lc = d % 9
d = Math.floor(d / 9)
const lp = d % 5
const pb = Math.floor(d / 5)
const dictSize = Math.max(properties[1] | (properties[2] << 8) | (properties[3] << 16) | (properties[4] << 24), 1 << 12) >>> 0
const out = Buffer.alloc(size)
let pos = 0
const rc = new RangeDecoder(input, 0)
const literals = probs(0x300 << (lc + lp))
const posSlot = probs(LEN_TO_POS_STATES << 6)
const align = probs(1 << ALIGN_BITS)
const posDecoders = probs(1 + FULL_DISTANCES - END_POS_MODEL_INDEX)
const isMatch = probs(STATES << POS_BITS_MAX)
const isRep = probs(STATES)
const isRepG0 = probs(STATES)
const isRepG1 = probs(STATES)
const isRepG2 = probs(STATES)
const isRep0Long = probs(STATES << POS_BITS_MAX)
const lenDecoder = new LenDecoder()
const repLenDecoder = new LenDecoder()
const distance = (len: number): number => {
const lenState = Math.min(len, LEN_TO_POS_STATES - 1)
const slot = rc.tree(posSlot, lenState << 6, 6)
if (slot < 4) return slot
const directBits = (slot >>> 1) - 1
let dist = ((2 | (slot & 1)) * 2 ** directBits) >>> 0
if (slot < END_POS_MODEL_INDEX) {
dist = (dist + rc.reverseTree(posDecoders, dist - slot, directBits)) >>> 0
} else {
dist = (dist + rc.direct(directBits - ALIGN_BITS) * 2 ** ALIGN_BITS) >>> 0
dist = (dist + rc.reverseTree(align, 0, ALIGN_BITS)) >>> 0
}
return dist
}
let state = 0
let rep0 = 0
let rep1 = 0
let rep2 = 0
let rep3 = 0
while (pos < size) {
const posState = pos & ((1 << pb) - 1)
if (rc.bit(isMatch, (state << POS_BITS_MAX) + posState) === 0) {
const prev = pos > 0 ? out[pos - 1] : 0
const base = 0x300 * (((pos & ((1 << lp) - 1)) << lc) + (prev >>> (8 - lc)))
let symbol = 1
if (state >= 7) {
let match = out[pos - rep0 - 1]
do {
const matchBit = (match >>> 7) & 1
match <<= 1
const bit = rc.bit(literals, base + ((1 + matchBit) << 8) + symbol)
symbol = (symbol << 1) | bit
if (matchBit !== bit) break
} while (symbol < 0x100)
}
while (symbol < 0x100) symbol = (symbol << 1) | rc.bit(literals, base + symbol)
out[pos++] = symbol - 0x100
state = state < 4 ? 0 : state < 10 ? state - 3 : state - 6
continue
}
let len: number
if (rc.bit(isRep, state) !== 0) {
if (pos === 0) throw new LzmaError('LZMA: repeat before any output')
if (rc.bit(isRepG0, state) === 0) {
if (rc.bit(isRep0Long, (state << POS_BITS_MAX) + posState) === 0) {
state = state < 7 ? 9 : 11
out[pos] = out[pos - rep0 - 1]
pos++
continue
}
} else {
let dist: number
if (rc.bit(isRepG1, state) === 0) {
dist = rep1
} else {
if (rc.bit(isRepG2, state) === 0) {
dist = rep2
} else {
dist = rep3
rep3 = rep2
}
rep2 = rep1
}
rep1 = rep0
rep0 = dist
}
len = repLenDecoder.decode(rc, posState)
state = state < 7 ? 8 : 11
} else {
rep3 = rep2
rep2 = rep1
rep1 = rep0
len = lenDecoder.decode(rc, posState)
state = state < 7 ? 7 : 10
rep0 = distance(len)
// The end marker: allowed only where the output is complete, which the loop already ended at.
if (rep0 === 0xffffffff) throw new LzmaError('LZMA: end marker before the end')
if (rep0 >= dictSize || rep0 >= pos) throw new LzmaError('LZMA: distance out of range')
}
len += MATCH_MIN_LEN
if (len > size - pos) throw new LzmaError('LZMA: match past the end')
for (let i = 0; i < len; i++, pos++) out[pos] = out[pos - rep0 - 1]
}
return out
}
+350
View File
@@ -0,0 +1,350 @@
import { crc32 } from 'node:zlib'
import { lzmaDecode } from '#lib/sevenzip/lzma'
/**
* What a 7z archive's header says (its listing): read from the archive's end, where 7z keeps it, so the last
* segment of the last volume is enough (the `7z-tail` probe; REBUILD.md, phase 2). The header there is mostly
* an encoded one: a pointer to the real header, LZMA-packed just before it, or AES-encrypted (`7z -mhe`), which
* hides the names and means a password.
*/
export interface SevenZipListing {
/** Files, not directories, with their unpacked size (0 for an empty one). */
files: { name: string; size: number }[]
/** Entries or the header encrypted (AES). */
encrypted: boolean
/** The header itself encrypted: no names known. */
headerEncrypted: boolean
}
const AES = '06f10701'
const LZMA = '030101'
/** A header bigger than this isn't unpacked (headers list names; 64 MiB is ~a million files). */
const MAX_HEADER = 64 * 1024 * 1024
const ID = {
end: 0x00,
header: 0x01,
archiveProperties: 0x02,
additionalStreamsInfo: 0x03,
mainStreamsInfo: 0x04,
filesInfo: 0x05,
packInfo: 0x06,
unpackInfo: 0x07,
subStreamsInfo: 0x08,
size: 0x09,
crc: 0x0a,
folder: 0x0b,
codersUnpackSize: 0x0c,
numUnpackStream: 0x0d,
emptyStream: 0x0e,
emptyFile: 0x0f,
name: 0x11,
encodedHeader: 0x17,
dummy: 0x19,
}
class Malformed extends Error {}
interface Coder {
id: string
properties: Uint8Array
inStreams: number
outStreams: number
}
interface Folder {
coders: Coder[]
bindOut: Set<number>
unpackSizes: number[]
crc?: number
}
interface StreamsInfo {
packPos: number
packSizes: number[]
folders: Folder[]
/** Per folder, how many files' streams it holds, and every stream's size in order. */
streamsPerFolder: number[]
streamSizes: number[]
}
class Reader {
constructor(
private buf: Uint8Array,
public pos = 0
) {}
get done() {
return this.pos >= this.buf.length
}
byte(): number {
if (this.pos >= this.buf.length) throw new Malformed('7z: header ended')
return this.buf[this.pos++]
}
bytes(n: number): Uint8Array {
if (n < 0 || this.pos + n > this.buf.length) throw new Malformed('7z: header ended')
const out = this.buf.subarray(this.pos, this.pos + n)
this.pos += n
return out
}
/** 7z's variable-length number: the first byte's high bits say how many bytes follow. */
number(): number {
const first = this.byte()
let mask = 0x80
let value = 0
for (let i = 0; i < 8; i++) {
if ((first & mask) === 0) {
value += (first & (mask - 1)) * 2 ** (8 * i)
break
}
value += this.byte() * 2 ** (8 * i)
mask >>>= 1
}
if (value > Number.MAX_SAFE_INTEGER) throw new Malformed('7z: number too large')
return value
}
uint32(): number {
const b = this.bytes(4)
return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0
}
bits(n: number): boolean[] {
const out: boolean[] = []
let byte = 0
for (let i = 0; i < n; i++) {
if (i % 8 === 0) byte = this.byte()
out.push((byte & (0x80 >>> i % 8)) !== 0)
}
return out
}
/** "All defined" or a bit vector, then a CRC per defined item. */
digests(n: number): (number | undefined)[] {
const allDefined = this.byte() !== 0
const defined = allDefined ? Array(n).fill(true) : this.bits(n)
return defined.map((d) => (d ? this.uint32() : undefined))
}
expect(id: number) {
if (this.byte() !== id) throw new Malformed(`7z: expected property ${id}`)
}
}
/** Counts that come from the data: a garbage position makes huge ones, which shouldn't allocate. */
function count(n: number, max = 1_000_000): number {
if (n > max) throw new Malformed('7z: implausible count')
return n
}
function readFolder(r: Reader): Folder {
const coders: Coder[] = []
const numCoders = count(r.number(), 64)
if (numCoders === 0) throw new Malformed('7z: folder without coders')
for (let i = 0; i < numCoders; i++) {
const flags = r.byte()
if (flags & 0x80) throw new Malformed('7z: alternative coder methods')
const id = Buffer.from(r.bytes(flags & 0x0f)).toString('hex')
const complex = (flags & 0x10) !== 0
const inStreams = complex ? count(r.number(), 64) : 1
const outStreams = complex ? count(r.number(), 64) : 1
const properties = flags & 0x20 ? r.bytes(count(r.number(), 1 << 16)) : new Uint8Array(0)
coders.push({ id, properties, inStreams, outStreams })
}
const totalOut = coders.reduce((n, c) => n + c.outStreams, 0)
const totalIn = coders.reduce((n, c) => n + c.inStreams, 0)
const bindOut = new Set<number>()
for (let i = 0; i < totalOut - 1; i++) {
r.number()
bindOut.add(r.number())
}
const packed = totalIn - (totalOut - 1)
if (packed < 1) throw new Malformed('7z: folder without packed streams')
if (packed > 1) for (let i = 0; i < packed; i++) r.number()
return { coders, bindOut, unpackSizes: [] }
}
/** The folder's output: the one out stream no bind pair consumes. */
function unpackSize(folder: Folder): number {
for (let i = folder.unpackSizes.length - 1; i >= 0; i--) if (!folder.bindOut.has(i)) return folder.unpackSizes[i]
return 0
}
function readStreamsInfo(r: Reader): StreamsInfo {
const info: StreamsInfo = { packPos: 0, packSizes: [], folders: [], streamsPerFolder: [], streamSizes: [] }
let id = r.byte()
if (id === ID.packInfo) {
info.packPos = r.number()
const n = count(r.number())
for (id = r.byte(); id !== ID.end; id = r.byte()) {
if (id === ID.size) for (let i = 0; i < n; i++) info.packSizes.push(r.number())
else if (id === ID.crc) r.digests(n)
else throw new Malformed('7z: unexpected property in pack info')
}
id = r.byte()
}
if (id === ID.unpackInfo) {
r.expect(ID.folder)
const n = count(r.number())
if (r.byte() !== 0) throw new Malformed('7z: external folders')
for (let i = 0; i < n; i++) info.folders.push(readFolder(r))
r.expect(ID.codersUnpackSize)
for (const f of info.folders) for (const c of f.coders) for (let i = 0; i < c.outStreams; i++) f.unpackSizes.push(r.number())
for (id = r.byte(); id !== ID.end; id = r.byte()) {
if (id === ID.crc) r.digests(n).forEach((crc, i) => (info.folders[i].crc = crc))
else throw new Malformed('7z: unexpected property in unpack info')
}
id = r.byte()
}
info.streamsPerFolder = info.folders.map(() => 1)
if (id === ID.subStreamsInfo) {
id = r.byte()
if (id === ID.numUnpackStream) {
info.streamsPerFolder = info.folders.map(() => count(r.number()))
id = r.byte()
}
const hasSizes = id === ID.size
info.folders.forEach((f, i) => {
const n = info.streamsPerFolder[i]
if (n === 0) return
let sum = 0
for (let j = 0; j < n - 1 && hasSizes; j++) {
const size = r.number()
info.streamSizes.push(size)
sum += size
}
info.streamSizes.push(unpackSize(f) - sum)
})
if (hasSizes) id = r.byte()
while (id !== ID.end) {
if (id === ID.crc) {
// CRCs for the streams whose folder had none (a folder of one stream with a CRC has its own).
const unknown = info.folders.reduce((n, f, i) => n + (info.streamsPerFolder[i] === 1 && f.crc !== undefined ? 0 : info.streamsPerFolder[i]), 0)
r.digests(unknown)
} else {
throw new Malformed('7z: unexpected property in substreams info')
}
id = r.byte()
}
id = r.byte()
} else {
info.streamSizes = info.folders.map(unpackSize)
}
if (id !== ID.end) throw new Malformed('7z: streams info not ended')
return info
}
function readFilesInfo(r: Reader, streams: StreamsInfo | null): { name: string; size: number }[] {
const n = count(r.number())
let emptyStream: boolean[] = Array(n).fill(false)
let emptyFile: boolean[] = []
let names: string[] = []
for (let id = r.byte(); id !== ID.end; id = r.byte()) {
const size = r.number()
const end = r.pos + size
if (id === ID.emptyStream) {
emptyStream = r.bits(n)
} else if (id === ID.emptyFile) {
emptyFile = r.bits(emptyStream.filter(Boolean).length)
} else if (id === ID.name) {
if (r.byte() !== 0) throw new Malformed('7z: external names')
const text = Buffer.from(r.bytes(size - 1)).toString('utf16le')
names = text.split('\0').slice(0, n)
} else if (id !== ID.dummy && id > 0x19) {
throw new Malformed('7z: unknown file property')
}
r.pos = end
}
if (names.length !== n) throw new Malformed('7z: names missing')
const out: { name: string; size: number }[] = []
let stream = 0
let empty = 0
for (let i = 0; i < n; i++) {
if (!emptyStream[i]) {
out.push({ name: names[i], size: streams?.streamSizes[stream++] ?? 0 })
continue
}
// An empty stream is a directory unless it's marked an empty file.
if (emptyFile[empty++]) out.push({ name: names[i], size: 0 })
}
return out
}
function readHeader(r: Reader): SevenZipListing {
let id = r.byte()
if (id === ID.archiveProperties) {
for (let type = r.byte(); type !== 0; type = r.byte()) r.bytes(r.number())
id = r.byte()
}
if (id === ID.additionalStreamsInfo) {
readStreamsInfo(r)
id = r.byte()
}
let streams: StreamsInfo | null = null
if (id === ID.mainStreamsInfo) {
streams = readStreamsInfo(r)
id = r.byte()
}
let files: { name: string; size: number }[] = []
if (id === ID.filesInfo) {
files = readFilesInfo(r, streams)
id = r.byte()
}
if (id !== ID.end) throw new Malformed('7z: header not ended')
const encrypted = (streams?.folders ?? []).some((f) => f.coders.some((c) => c.id === AES))
return { files, encrypted, headerEncrypted: false }
}
/**
* The header that ends exactly at `end` in `data`, starting at `start`: a plain one, or an encoded one whose packed
* data lies just before it (where 7z writes it). Throws when it isn't one.
*/
function headerAt(data: Uint8Array, start: number, end: number): SevenZipListing {
const r = new Reader(data.subarray(0, end), start)
const id = r.byte()
if (id === ID.header) {
const listing = readHeader(r)
if (!r.done) throw new Malformed('7z: bytes after the header')
return listing
}
if (id !== ID.encodedHeader) throw new Malformed('7z: not a header')
const info = readStreamsInfo(r)
if (!r.done) throw new Malformed('7z: bytes after the encoded header')
if (info.folders.length !== 1 || info.packSizes.length < 1) throw new Malformed('7z: unexpected encoded header')
const folder = info.folders[0]
if (folder.coders.some((c) => c.id === AES)) return { files: [], encrypted: true, headerEncrypted: true }
if (folder.coders.length !== 1 || folder.coders[0].id !== LZMA) throw new Malformed(`7z: header coder ${folder.coders[0].id} not supported`)
const packSize = info.packSizes[0]
const size = unpackSize(folder)
if (packSize > start || size > MAX_HEADER) throw new Malformed('7z: packed header not in the data')
const header = lzmaDecode(folder.coders[0].properties, data.subarray(start - packSize, start), size)
if (folder.crc !== undefined && crc32(header) >>> 0 !== folder.crc) throw new Malformed('7z: header CRC mismatch')
return headerAt(header, 0, header.length)
}
/**
* The listing of the 7z archive that `data` is the end of (the last segment of its last volume, or a whole small
* archive); null when no header ends there.
*/
export function parseSevenZipTail(data: Uint8Array): SevenZipListing | null {
const end = data.length
// An encoded header is a few dozen bytes; a plain one (7z -mhc=off) can be large.
for (let start = end - 2; start >= Math.max(0, end - 1024); start--) {
if (data[start] !== ID.encodedHeader || data[start + 1] !== ID.packInfo) continue
try {
return headerAt(data, start, end)
} catch {
// Not a header here (any byte can look like the start of one): the next position.
}
}
for (let start = end - 2; start >= 0; start--) {
if (data[start] !== ID.header || ![ID.archiveProperties, ID.additionalStreamsInfo, ID.mainStreamsInfo, ID.filesInfo, ID.end].includes(data[start + 1])) continue
try {
return headerAt(data, start, end)
} catch {
// Not a header here (any byte can look like the start of one): the next position.
}
}
return null
}
+1
View File
@@ -0,0 +1 @@
export { parseZipStart, type ZipStart } from '#lib/zip/parse'
+50
View File
@@ -0,0 +1,50 @@
/**
* 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
}