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>
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
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)
|
||
}
|