85% of the 7z archives inspected have encrypted headers (-mhe). Their encoded header lists the 7zAES coder, so the packed header stream is no longer downloaded and bsdtar no longer runs for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
import { crc32 } from 'node:zlib'
|
|
|
|
/**
|
|
* 7-Zip archives, independent of AdonisJS. A 7z archive starts with a
|
|
* 32-byte signature header that points to the archive's header (the file
|
|
* list) at its very end, often LZMA-compressed ("encoded") with its packed
|
|
* data just before it. So the list needs the first 32 bytes and the tail:
|
|
* for a split archive (.7z.001, .002, …), the start of the first volume and
|
|
* the end of the last. Listing itself is left to libarchive (bsdtar).
|
|
*/
|
|
|
|
const SIGNATURE = Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c])
|
|
const SIGNATURE_HEADER_SIZE = 32
|
|
/** Header property ids (7zFormat.txt). */
|
|
const K_END = 0x00
|
|
const K_PACK_INFO = 0x06
|
|
const K_UNPACK_INFO = 0x07
|
|
const K_SIZE = 0x09
|
|
const K_CRC = 0x0a
|
|
const K_FOLDER = 0x0b
|
|
const K_ENCODED_HEADER = 0x17
|
|
|
|
export interface SevenZipStart {
|
|
/** Where the archive header starts, from the start of the archive. */
|
|
headerStart: number
|
|
headerSize: number
|
|
headerCrc: number
|
|
/** The archive's total size (the header is its last part). */
|
|
archiveSize: number
|
|
}
|
|
|
|
/** Reads the signature header; null when `data` isn't a 7z start or its CRC fails. */
|
|
export function parseSevenZipStart(data: Buffer): SevenZipStart | null {
|
|
if (data.length < SIGNATURE_HEADER_SIZE || !data.subarray(0, 6).equals(SIGNATURE)) return null
|
|
if ((crc32(data.subarray(12, 32)) >>> 0) !== data.readUInt32LE(8)) return null
|
|
const offset = Number(data.readBigUInt64LE(12))
|
|
const size = Number(data.readBigUInt64LE(20))
|
|
const headerStart = SIGNATURE_HEADER_SIZE + offset
|
|
return { headerStart, headerSize: size, headerCrc: data.readUInt32LE(28), archiveSize: headerStart + size }
|
|
}
|
|
|
|
/** Whether `header` (the bytes at headerStart) is the archive header its signature header describes. */
|
|
export function checkHeader(start: SevenZipStart, header: Buffer): boolean {
|
|
return header.length === start.headerSize && (crc32(header) >>> 0) === start.headerCrc
|
|
}
|
|
|
|
/**
|
|
* Where the bytes needed for listing start: the header itself, or for an
|
|
* encoded header the packed (compressed) header stream it points to.
|
|
*/
|
|
export function listingStart(start: SevenZipStart, header: Buffer): number {
|
|
if (header[0] !== K_ENCODED_HEADER || header[1] !== K_PACK_INFO) return start.headerStart
|
|
const [packPos] = readNumber(header, 2)
|
|
return SIGNATURE_HEADER_SIZE + packPos
|
|
}
|
|
|
|
/** 7zAES, the coder of password-protected streams. */
|
|
const AES_CODER = Buffer.from([0x06, 0xf1, 0x07, 0x01])
|
|
|
|
/**
|
|
* Whether an encoded header (`header`, the bytes at headerStart) is
|
|
* encrypted (7z -mhe): its coders include 7zAES. Then nothing can be listed
|
|
* without the password, and neither the packed header nor bsdtar is needed.
|
|
* Null when the header isn't encoded or doesn't read as expected.
|
|
*/
|
|
export function encodedHeaderEncrypted(header: Buffer): boolean | null {
|
|
try {
|
|
if (header[0] !== K_ENCODED_HEADER || header[1] !== K_PACK_INFO) return null
|
|
let pos = 2
|
|
let numPackStreams: number
|
|
;[, pos] = readNumber(header, pos) // packPos
|
|
;[numPackStreams, pos] = readNumber(header, pos)
|
|
for (let id = header[pos++]; id !== K_END; id = header[pos++]) {
|
|
if (id === K_SIZE) {
|
|
for (let i = 0; i < numPackStreams; i++) [, pos] = readNumber(header, pos)
|
|
} else if (id === K_CRC) {
|
|
const allDefined = header[pos++]
|
|
let defined = numPackStreams
|
|
if (!allDefined) {
|
|
const bits = header.subarray(pos, pos + Math.ceil(numPackStreams / 8))
|
|
pos += bits.length
|
|
defined = [...bits].reduce((sum, byte) => sum + byte.toString(2).replaceAll('0', '').length, 0)
|
|
}
|
|
pos += 4 * defined
|
|
} else {
|
|
return null
|
|
}
|
|
if (pos >= header.length) return null
|
|
}
|
|
if (header[pos++] !== K_UNPACK_INFO || header[pos++] !== K_FOLDER) return null
|
|
let numFolders: number
|
|
;[numFolders, pos] = readNumber(header, pos)
|
|
if (numFolders < 1 || header[pos++] !== 0) return null // folders stored elsewhere
|
|
let numCoders: number
|
|
;[numCoders, pos] = readNumber(header, pos)
|
|
if (!(numCoders >= 1) || pos >= header.length) return null
|
|
for (let c = 0; c < numCoders; c++) {
|
|
const flags = header[pos++]
|
|
const idSize = flags & 0x0f
|
|
if (header.subarray(pos, pos + idSize).equals(AES_CODER)) return true
|
|
pos += idSize
|
|
if (flags & 0x10) {
|
|
;[, pos] = readNumber(header, pos)
|
|
;[, pos] = readNumber(header, pos)
|
|
}
|
|
if (flags & 0x20) {
|
|
let size: number
|
|
;[size, pos] = readNumber(header, pos)
|
|
pos += size
|
|
}
|
|
if (pos > header.length) return null
|
|
}
|
|
return false
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 7z's variable-length number: the leading 1-bits of the first byte count
|
|
* the little-endian bytes that follow; its remaining bits are the high part.
|
|
*/
|
|
export function readNumber(data: Buffer, pos: number): [value: number, next: number] {
|
|
const first = data[pos]
|
|
let mask = 0x80
|
|
let value = 0
|
|
for (let i = 0; i < 8; i++) {
|
|
if ((first & mask) === 0) {
|
|
value += (first & (mask - 1)) * 2 ** (8 * i)
|
|
return [value, pos + 1 + i]
|
|
}
|
|
value += data[pos + 1 + i] * 2 ** (8 * i)
|
|
mask >>= 1
|
|
}
|
|
return [value, pos + 9]
|
|
}
|
|
|
|
export interface SevenZipEntry {
|
|
name: string
|
|
size: number
|
|
directory: boolean
|
|
}
|
|
|
|
/**
|
|
* Entries from `bsdtar -tv` output, e.g.
|
|
* `-rw-rw-r-- 0 0 0 3000000 Sep 30 19:13 Some.Release/movie.mkv`.
|
|
*/
|
|
export function parseBsdtarListing(stdout: string): SevenZipEntry[] {
|
|
const entries: SevenZipEntry[] = []
|
|
for (const line of stdout.split('\n')) {
|
|
const match = /^([-dlbcps])\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{2}|\d{4})\s(.+)$/.exec(line)
|
|
if (!match) continue
|
|
const directory = match[1] === 'd'
|
|
entries.push({ name: directory ? match[3].replace(/\/$/, '') : match[3], size: Number(match[2]), directory })
|
|
}
|
|
return entries
|
|
}
|
|
|
|
/** Volume number of a split 7z name (`x.7z.001` → 1), 0 for an unsplit `.7z`, null otherwise. */
|
|
export function sevenZipVolume(name: string): number | null {
|
|
const split = /\.7z\.(\d{1,4})$/i.exec(name)
|
|
if (split) return Number(split[1])
|
|
return /\.7z$/i.test(name) ? 0 : null
|
|
}
|