import { crc32 } from 'node:zlib' export interface RarEntry { name: string /** Unpacked size in bytes (null when the archiver did not record it). */ size: number | null packedSize: number directory: boolean /** The file's data is password protected. */ encrypted: boolean /** Continues from the previous volume. */ splitBefore: boolean /** Continues in the next volume. */ splitAfter: boolean /** Stored without compression, so the data can be copied out as is. */ stored: boolean /** Offset of the entry's (packed) data in the parsed buffer. */ dataOffset: number } export interface RarInfo { /** 7z listings take the same shape (see lib/sevenzip). */ format: 'rar4' | 'rar5' | '7z' /** Part of a multi-volume set. */ volume: boolean /** First volume of the set (null when the format version does not say). */ firstVolume: boolean | null solid: boolean /** File names are encrypted too; nothing can be listed without the password. */ headersEncrypted: boolean entries: RarEntry[] /** The buffer ended before the end-of-archive header (normal for one segment). */ truncated: boolean } const RAR4_MARKER = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]) const RAR5_MARKER = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]) export function rarFormat(data: Buffer): 'rar4' | 'rar5' | null { if (data.subarray(0, RAR5_MARKER.length).equals(RAR5_MARKER)) return 'rar5' if (data.subarray(0, RAR4_MARKER.length).equals(RAR4_MARKER)) return 'rar4' return null } /** * Lists a RAR archive from its headers only, so the first segment of a first * volume is enough. Returns null when the data is not a RAR archive. */ export function parseRarHeaders(data: Buffer): RarInfo | null { const format = rarFormat(data) if (format === 'rar4') return parseRar4(data) if (format === 'rar5') return parseRar5(data) return null } // --- RAR 4.x (RAR 1.5 – 4.x archive format) ------------------------------- const R4_MAIN = 0x73 const R4_FILE = 0x74 const R4_END = 0x7b const R4_LONG_BLOCK = 0x8000 function parseRar4(data: Buffer): RarInfo { const info: RarInfo = { format: 'rar4', volume: false, firstVolume: null, solid: false, headersEncrypted: false, entries: [], truncated: true, } let p = RAR4_MARKER.length while (p + 7 <= data.length) { const headCrc = data.readUInt16LE(p) const type = data[p + 2] const flags = data.readUInt16LE(p + 3) const size = data.readUInt16LE(p + 5) if (size < 7 || p + size > data.length) break if (type === R4_END) { info.truncated = false break } let next = p + size + (flags & R4_LONG_BLOCK && size >= 11 ? data.readUInt32LE(p + 7) : 0) if (type === R4_MAIN) { info.volume = Boolean(flags & 0x0001) info.solid = Boolean(flags & 0x0008) info.firstVolume = info.volume ? Boolean(flags & 0x0100) : true if (flags & 0x0080) { info.headersEncrypted = true break } } else if (type === R4_FILE) { if (size < 32 || (crc32(data.subarray(p + 2, p + size)) & 0xffff) !== headCrc) break const large = Boolean(flags & 0x0100) let packed = data.readUInt32LE(p + 7) let unpacked = data.readUInt32LE(p + 11) const nameSize = data.readUInt16LE(p + 26) let nameStart = p + 32 if (large) { packed += data.readUInt32LE(p + 32) * 2 ** 32 unpacked += data.readUInt32LE(p + 36) * 2 ** 32 nameStart = p + 40 } const rawName = data.subarray(nameStart, nameStart + nameSize) info.entries.push({ name: flags & 0x0200 ? decodeRar4Name(rawName) : rawName.toString('latin1'), // 0xffffffff with LHD_LARGE unset means "unknown" in some archivers. size: unpacked === 0xffffffff && !large ? null : unpacked, packedSize: packed, directory: (flags & 0x00e0) === 0x00e0, encrypted: Boolean(flags & 0x0004), splitBefore: Boolean(flags & 0x0001), splitAfter: Boolean(flags & 0x0002), stored: data[p + 25] === 0x30, dataOffset: p + size, }) next = p + size + packed } if (next <= p) break p = next } // Archivers before RAR 3.0 don't set the first-volume flag; a first // volume is one whose first file does not continue from a previous one. if (info.volume && !info.firstVolume && info.entries.length > 0 && !info.entries[0].splitBefore) { info.firstVolume = true } return info } /** * RAR 4 stores Unicode names as "ASCII\0", where the * compressed part refers back to the ASCII bytes (unrar's DecodeFileName). */ export function decodeRar4Name(raw: Buffer): string { const zero = raw.indexOf(0) if (zero === -1) return raw.toString('utf8') const ascii = raw.subarray(0, zero) const enc = raw.subarray(zero + 1) if (enc.length === 0) return ascii.toString('latin1') const out: number[] = [] const highByte = enc[0] let encPos = 1 let flags = 0 let flagBits = 0 while (encPos < enc.length) { if (flagBits === 0) { flags = enc[encPos++] flagBits = 8 } switch (flags >> 6) { case 0: out.push(enc[encPos++]) break case 1: out.push(enc[encPos++] + (highByte << 8)) break case 2: out.push(enc[encPos] + (enc[encPos + 1] << 8)) encPos += 2 break case 3: { let length = enc[encPos++] if (length & 0x80) { const correction = enc[encPos++] for (length = (length & 0x7f) + 2; length > 0 && out.length < ascii.length; length--) { out.push(((ascii[out.length] + correction) & 0xff) + (highByte << 8)) } } else { for (length += 2; length > 0 && out.length < ascii.length; length--) { out.push(ascii[out.length]) } } break } } flags = (flags << 2) & 0xff flagBits -= 2 } return String.fromCharCode(...out) } // --- RAR 5.0 ---------------------------------------------------------------- const R5_MAIN = 1 const R5_FILE = 2 const R5_ENCRYPTION = 4 const R5_END = 5 /** Reads a RAR5 variable-length integer: 7 bits per byte, little-endian. */ function vint(data: Buffer, offset: number): { value: number; next: number } | null { let value = 0 let shift = 1 for (let i = offset; i < data.length && i < offset + 10; i++) { value += (data[i] & 0x7f) * shift shift *= 128 if (!(data[i] & 0x80)) return { value, next: i + 1 } } return null } function parseRar5(data: Buffer): RarInfo { const info: RarInfo = { format: 'rar5', volume: false, firstVolume: null, solid: false, headersEncrypted: false, entries: [], truncated: true, } let p = RAR5_MARKER.length while (p + 5 <= data.length) { const headCrc = data.readUInt32LE(p) const headerSize = vint(data, p + 4) if (!headerSize || headerSize.value === 0) break const headerEnd = headerSize.next + headerSize.value if (headerEnd > data.length) break if ((crc32(data.subarray(p + 4, headerEnd)) >>> 0) !== headCrc) break const type = vint(data, headerSize.next) const flags = type && vint(data, type.next) if (!type || !flags) break let cursor = flags.next let extraSize = 0 let dataSize = 0 if (flags.value & 0x0001) { const v = vint(data, cursor) if (!v) break extraSize = v.value cursor = v.next } if (flags.value & 0x0002) { const v = vint(data, cursor) if (!v) break dataSize = v.value cursor = v.next } if (type.value === R5_END) { info.truncated = false break } if (type.value === R5_ENCRYPTION) { info.headersEncrypted = true break } if (type.value === R5_MAIN) { const archiveFlags = vint(data, cursor) if (!archiveFlags) break info.volume = Boolean(archiveFlags.value & 0x0001) // A volume number is only stored for volumes after the first. info.firstVolume = info.volume ? !(archiveFlags.value & 0x0002) : true info.solid = Boolean(archiveFlags.value & 0x0004) } else if (type.value === R5_FILE) { const entry = parseRar5File(data, cursor, headerEnd - extraSize, headerEnd, flags.value, dataSize) if (!entry) break info.entries.push(entry) } const next = headerEnd + dataSize if (next <= p) break p = next } return info } function parseRar5File( data: Buffer, start: number, extraStart: number, headerEnd: number, headerFlags: number, dataSize: number ): RarEntry | null { const fileFlags = vint(data, start) const unpacked = fileFlags && vint(data, fileFlags.next) const attributes = unpacked && vint(data, unpacked.next) if (!fileFlags || !unpacked || !attributes) return null let cursor = attributes.next if (fileFlags.value & 0x0002) cursor += 4 // mtime if (fileFlags.value & 0x0004) cursor += 4 // data CRC32 const compression = vint(data, cursor) const hostOs = compression && vint(data, compression.next) const nameLength = hostOs && vint(data, hostOs.next) if (!compression || !hostOs || !nameLength) return null const name = data.subarray(nameLength.next, nameLength.next + nameLength.value).toString('utf8') // Compression information: bits 7-9 are the method, 0 is "store". const method = Math.floor(compression.value / 128) & 0x7 // Extra area records: size, type, data. Type 1 is file encryption. let encrypted = false let e = extraStart while (e < headerEnd) { const recordSize = vint(data, e) const recordType = recordSize && vint(data, recordSize.next) if (!recordSize || !recordType || recordSize.value === 0) break if (recordType.value === 0x01) encrypted = true e = recordSize.next + recordSize.value } return { name, size: fileFlags.value & 0x0008 ? null : unpacked.value, packedSize: dataSize, directory: Boolean(fileFlags.value & 0x0001), encrypted, splitBefore: Boolean(headerFlags & 0x0008), splitAfter: Boolean(headerFlags & 0x0010), stored: method === 0, dataOffset: headerEnd, } } /** * The data of an entry stored without compression, copied out of the * archive buffer. Null when it is compressed, encrypted, split over volumes * or not completely in the buffer. */ export function storedEntryData(data: Buffer, entry: RarEntry): Buffer | null { if (!entry.stored || entry.encrypted || entry.directory || entry.splitBefore || entry.splitAfter) return null if (entry.size === null || entry.size !== entry.packedSize) return null const end = entry.dataOffset + entry.packedSize if (end > data.length) return null return data.subarray(entry.dataOffset, end) }