Files
usenet-indexer/lib/rar/parse.ts
T
Daan MeijerandClaude Opus 5.5 be8a3d4580 Spotnet import, header backfill, staging and counter grouping, faster search
Spotnet (CONCEPT D29):
- Index free.pt fully and import spots with their descriptions, categories
  and NZBs; a spot renames its release, since Spotnet names are more
  trustworthy than names derived from subjects.
- Find passwords in spot descriptions too, and tell "needs a password we
  have" from "needs a password we don't have".

Content inspection:
- List the contents of 7z (.7z.001) archives, SFV sets, nicovideo uploads
  and other archive-like files; mark yEncBin .bin containers as encrypted.
- Take real names from yEnc headers for randomly named files, and merge
  uploads that were split across posts.

Storage (D31, D32):
- Store segment lists zstd-compressed behind the FileStore accessor.
- group:backfill indexes older headers at low priority and pauses when
  the NVMe or HDD part of the database gets too large.

Grouping (D35, D36):
- Lone header segments wait in staged_segments until a second segment of
  their file arrives, which drops the per-article junk binaries.
- Counter grouping joins random-subject uploads by poster, [i/M] counter,
  part count and day.

Search and maintenance (D33, D34):
- A trigram-indexed search_text column, smaller indexes, estimated stats.
- Passes that clean up nonsense names; weekly index maintenance; GC budgets
  per kind.
- Web UI: grey out stale results while a search runs and cancel superseded
  requests. Count only up to 1,000 matches for the page, which made filter
  clicks take seconds (Newznab keeps the 10,000 cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:59:23 +02:00

335 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<compressed UTF-16>", 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)
}