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

+1 -1
View File
@@ -260,7 +260,7 @@ with 68,032 releases with a password):
| Passwords (from spots, NZB meta, names) | D29, D26 | from spots (title, text) and their NZBs' meta, in `release_passwords`; else only *whether* passworded (the prober); the export's 68 k not imported |
| SFV grouping and naming | D34 | not yet (SFV files aren't probe candidates) |
| Complete RAR listings by walking volume headers | D40 | no: the prober reads the first volume's first article only |
| 7z listings | D19 | no: `7z-tail` candidates are queued, not probed; one release keeps only the tails of its highest volume (the live tail's pieces each queued one; `seven_zip_tails`) |
| 7z listings | D19 | yes (2026-10-23): the prober reads the `7z-tail` (the last segment of the highest volume, or of an unsplit multi-segment .7z) and `lib/sevenzip` lists it from the archive's end header (LZMA-packed, decoded in TypeScript); an encrypted header (`-mhe`) means passworded without names. One release keeps only the tails of its highest volume (`seven_zip_tails`). ZIP: the first segment's local headers (first entries, encryption) |
| Names from yEnc bodies for obfuscated subjects | D13, D21, D36 | no: obfuscated and per-article subjects are only counted (part 5); their message-ids aren't stored, so a body path needs those ranges read again |
| Garbage dropped after probing (no real name, no password) | D42 (its second filter) | the junk judge hides it (below); nothing dropped yet |
+5 -2
View File
@@ -20,7 +20,8 @@ type Outcome = { candidate: Candidate; status: 'done' | 'missing' | 'error' | 'w
/**
* The order candidates are probed in: what the par2 merge needs first (a random-named file's own start, then the
* par2 sets and archives of releases without a readable name), then the rest (names and passwords of readable ones).
* par2 sets and archives of releases without a readable name), then the rest (names and passwords of readable ones),
* then 7z listings (the end of a split 7z's last volume; queued from 2026-10-13, probed from 2026-10-23).
*/
const TIERS: { kind: string; unreadable: boolean }[] = [
{ kind: 'lone', unreadable: false },
@@ -28,6 +29,8 @@ const TIERS: { kind: string; unreadable: boolean }[] = [
{ kind: 'archive', unreadable: true },
{ kind: 'par2', unreadable: false },
{ kind: 'archive', unreadable: false },
{ kind: '7z-tail', unreadable: true },
{ kind: '7z-tail', unreadable: false },
]
const FLUSH_MS = 2000
@@ -169,7 +172,7 @@ export class Prober {
}
this.stats.bytes += body.length
try {
this.outcomes.push({ candidate, status: 'done', inspection: inspect(body) })
this.outcomes.push({ candidate, status: 'done', inspection: inspect(body, candidate.kind) })
} catch (error: any) {
// Not yEnc, a broken one, or data a parser chokes on: a final answer, not a connection problem.
if (!(error instanceof YencError)) logger.warn(`Probing ${candidate.messageId}: ${error.message}`)
+1 -1
View File
@@ -59,7 +59,7 @@ export const RULES: Rule[] = [
// An archive whose entries are all executables, under a media name (its own or its entries'); or a file that is
// an executable by name or by its first bytes, under a media name.
sql: (scoped) => `SELECT i.release_id FROM release_inspections i JOIN releases r ON r.id = i.release_id ${only(scoped, 'i.release_id')}
WHERE i.kind = 'archive' AND jsonb_array_length(i.result->'names') > 0
WHERE i.kind IN ('archive', '7z-tail') AND jsonb_array_length(i.result->'names') > 0
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(i.result->'names') n WHERE n !~* '${EXECUTABLE}')
AND (r.name ~* '${MEDIA}' OR (i.result->>'names') ~* '${MEDIA}')
UNION
+40 -1
View File
@@ -915,12 +915,17 @@ fn candidates_of(key: &str, files: &[Vec<Seg>], upload: usize) -> Vec<Candidate>
let c = |kind, s: &Seg| Candidate { release_key: key.to_string(), upload, kind, message_id: s.message_id.clone(), file: s.file.name.clone() };
for f in files {
let name = &f[0].file.name;
if let Some(volume) = names::seven_zip_volume(name).filter(|&v| v > 1) {
let volume = names::seven_zip_volume(name);
if let Some(volume) = volume.filter(|&v| v > 1) {
if tail.is_none_or(|(v, _)| volume > v) {
tail = Some((volume, f));
}
continue;
}
// An unsplit 7z of more than one segment: its listing is in its last one.
if volume == Some(0) && f.len() > 1 && tail.is_none() {
tail = Some((0, f));
}
if names::is_archive_start(name) {
out.push(c("archive", &f[0]));
} else if names::is_par2(name) && !names::is_later_volume(name) {
@@ -965,6 +970,40 @@ mod tests {
assert_eq!(batch.releases[0].key, batch.releases[1].key);
}
#[test]
fn archive_candidates_as_in_the_typescript() {
let mut priorities = FxHashMap::default();
priorities.insert(GROUP.to_string(), 1);
let mut ix = RangeIndexer::new(GROUP, 1, 10_000, 1, Rules::defaults(priorities), 5);
let file = |at: i64, release: &str, name: &str, n: usize, parts: i64| -> Vec<String> {
(0..parts).map(|i| line(at + i, &format!(r#"{release} [{n}/3] - "{name}" yEnc ({}/{parts})"#, i + 1), 0)).collect()
};
let mut lines = Vec::new();
// A split 7z: its start, and the end of its highest volume.
lines.extend(file(100, "Split.Seven", "Split.Seven.7z.001", 1, 2));
lines.extend(file(110, "Split.Seven", "Split.Seven.7z.003", 3, 2));
lines.extend(file(120, "Split.Seven", "Split.Seven.7z.002", 2, 2));
// An unsplit 7z of 3 segments: its start and its end.
lines.extend(file(200, "Whole.Seven", "Whole.Seven.7z", 1, 3));
// A ZIP: its start.
lines.extend(file(300, "Some.Zip", "Some.Zip.zip", 1, 2));
ix.add_text(&lines.join("\r\n"), 10_001);
ix.finish();
let batch = ix.take();
let mut got: Vec<(&str, &str, &str)> = batch.candidates.iter().map(|c| (c.kind, c.file.as_str(), c.message_id.as_str())).collect();
got.sort();
assert_eq!(
got,
vec![
("7z-tail", "Split.Seven.7z.003", "u111@test"),
("7z-tail", "Whole.Seven.7z", "u202@test"),
("archive", "Some.Zip.zip", "u300@test"),
("archive", "Split.Seven.7z.001", "u100@test"),
("archive", "Whole.Seven.7z", "u200@test"),
]
);
}
#[test]
fn copies_and_fills_as_in_the_typescript() {
let hour = 3_600_000i64;
+1 -1
View File
@@ -1030,7 +1030,7 @@ static SEVEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.7z$"));
static PART1: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.part0*1\.rar$"));
static RAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.rar$"));
static PART: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.part([0-9]+)\.rar$"));
static BIN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.bin$"));
static BIN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(bin|zip)$"));
static OLD_RAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.r[0-9]{2,3}$"));
static PAR2_VOL: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.vol[0-9]+[+-][0-9]+\.par2$"));
pub static PAR2: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.par2$"));
+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
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4017
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+14
View File
@@ -55,6 +55,20 @@ test.group('edge / RangeIndexer', () => {
assert.equal(batch.next, 1_000_001)
})
test("archive candidates: a split 7z's start and its highest volume's end, an unsplit 7z's start and end, a ZIP's start", ({ assert }) => {
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1_000_000, rules, edgeArticles: 10 })
indexer.add([
...post(100, 'Split.Seven.7z.001', 2, { counter: 'Split.Seven [1/3]' }),
...post(110, 'Split.Seven.7z.003', 2, { counter: 'Split.Seven [3/3]' }),
...post(120, 'Split.Seven.7z.002', 2, { counter: 'Split.Seven [2/3]' }),
...post(200, 'Whole.Seven.7z', 3, { counter: 'Whole.Seven [1/1]' }),
...post(300, 'Some.Zip.zip', 2, { counter: 'Some.Zip [1/1]' }),
], 200_000)
indexer.finish()
const got = indexer.take().candidates.map((c) => `${c.kind} ${c.file}`).sort()
assert.deepEqual(got, ['7z-tail Split.Seven.7z.003', '7z-tail Whole.Seven.7z', 'archive Some.Zip.zip', 'archive Split.Seven.7z.001', 'archive Whole.Seven.7z'])
})
test('files the edges cut leave as fragments; one missing parts in the middle is only counted', ({ assert }) => {
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1_000_000, rules, edgeArticles: 1000 })
indexer.add([
+42
View File
@@ -1,5 +1,6 @@
import { test } from '@japa/runner'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { crc32 } from 'node:zlib'
import yencode from 'yencode'
import { crc32Hex } from '#lib/yenc/index'
@@ -148,4 +149,45 @@ test.group('probe / innerName', () => {
// Nor does packaging: a lone par2 volume.
assert.isNull(innerName([{ kind: 'lone', result: { yencName: 'a1b2.vol007+08.par2', size: 1, type: 'par2' } }], 'x9y8'))
})
test("a 7z-tail: the listing from the last segment of the last volume; one that isn't the last segment: none", ({ assert }) => {
const tail = readFileSync(new URL('../../fixtures/sevenzip/split-last-volume.tail', import.meta.url))
// The volume's last 8,000 bytes as its third part, of a volume 1,000,000 bytes long.
const last = tail.subarray(tail.length - 8000)
const part = (data: Buffer, begin: number) =>
Buffer.from(
[`=ybegin part=3 total=3 line=128 size=1000000 name=x.7z.004`, `=ypart begin=${begin} end=${begin + data.length - 1}`, yencode.encode(data).toString('latin1'), `=yend size=${data.length} part=3 pcrc32=${crc32Hex(data)}`].join('\r\n'),
'latin1'
)
const result = inspect(part(last, 1_000_000 - 8000 + 1), '7z-tail')
assert.equal(result.entries, 4)
assert.deepEqual(result.names?.[0], 'Some.Movie.2020.1080p.BluRay.x264-GRP/some.movie.2020.1080p.bluray.x264-grp.mkv')
assert.isFalse(result.passworded)
assert.isUndefined(inspect(part(last, 1), '7z-tail').names)
const encrypted = readFileSync(new URL('../../fixtures/sevenzip/header-encrypted.tail', import.meta.url))
const hidden = inspect(part(encrypted.subarray(encrypted.length - 8000), 1_000_000 - 8000 + 1), '7z-tail')
assert.isTrue(hidden.passworded)
assert.equal(hidden.entries, 0)
assert.isUndefined(hidden.names)
// A small 7z whole in one article, probed as an archive start.
assert.deepEqual(inspect(article(readFileSync(new URL('../../fixtures/sevenzip/tiny.7z', import.meta.url)), 'x.7z'), 'archive').names, ['b.nfo', 'a.txt'])
})
test('a ZIP start: the names of its first entries, and whether they are encrypted', ({ assert }) => {
const local = (name: string, data: Buffer, flags = 0) => {
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50, 0)
header.writeUInt16LE(flags, 6)
header.writeUInt32LE(data.length, 18)
header.writeUInt32LE(data.length, 22)
header.writeUInt16LE(Buffer.byteLength(name), 26)
return Buffer.concat([header, Buffer.from(name), data])
}
const zip = Buffer.concat([local('Album/', Buffer.alloc(0)), local('Album/01 - Track.flac', Buffer.alloc(3000, 1)), local('Album/cover.jpg', Buffer.alloc(500, 2))])
const plain = inspect(article(zip, 'album.zip'))
assert.equal(plain.type, 'zip')
assert.deepEqual(plain.names, ['Album/01 - Track.flac', 'Album/cover.jpg'])
assert.isFalse(plain.passworded)
assert.isTrue(inspect(article(local('secret.mkv', Buffer.alloc(100), 0x1), 'x.zip')).passworded)
})
})
+20
View File
@@ -0,0 +1,20 @@
import { test } from '@japa/runner'
import { readFileSync } from 'node:fs'
import { lzmaDecode, LzmaError } from '#lib/sevenzip/lzma'
const fixture = (name: string) => readFileSync(new URL(`../../fixtures/sevenzip/${name}`, import.meta.url))
test.group('probe / LZMA', () => {
test('decodes a stream Python made (.lzma: 5 property bytes, the size, the raw stream)', ({ assert }) => {
const alone = fixture('sample.lzma')
const expected = fixture('sample.bin')
const decoded = lzmaDecode(alone.subarray(0, 5), alone.subarray(13), expected.length)
assert.isTrue(decoded.equals(expected))
})
test('a cut-off or damaged stream throws LzmaError', ({ assert }) => {
const alone = fixture('sample.lzma')
const size = fixture('sample.bin').length
assert.throws(() => lzmaDecode(alone.subarray(0, 5), alone.subarray(13, 200), size), LzmaError)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { test } from '@japa/runner'
import { readFileSync } from 'node:fs'
import { parseSevenZipTail } from '#lib/sevenzip/index'
const fixture = (name: string) => readFileSync(new URL(`../../fixtures/sevenzip/${name}`, import.meta.url))
const DIR = 'Some.Movie.2020.1080p.BluRay.x264-GRP'
test.group('probe / 7z listing from the archive end', () => {
test('an LZMA-packed header (7z default): files with sizes, directories left out', ({ assert }) => {
const listing = parseSevenZipTail(fixture('plain.tail'))
assert.deepEqual(listing, {
files: [
{ name: `${DIR}/empty.txt`, size: 0 },
{ name: `${DIR}/Sample/sample.mkv`, size: 20000 },
{ name: `${DIR}/grp.nfo`, size: 4 },
{ name: `${DIR}/some.movie.2020.1080p.bluray.x264-grp.mkv`, size: 300000 },
],
encrypted: false,
headerEncrypted: false,
})
})
test('an uncompressed header (7z -mhc=off) and the last volume of a split archive', ({ assert }) => {
for (const name of ['uncompressed-header.tail', 'split-last-volume.tail']) {
const listing = parseSevenZipTail(fixture(name))!
assert.lengthOf(listing.files, 4, name)
assert.isFalse(listing.encrypted, name)
}
})
test('encrypted entries: names listed, encrypted; an encrypted header (7z -mhe): no names, encrypted', ({ assert }) => {
const files = parseSevenZipTail(fixture('files-encrypted.tail'))!
assert.lengthOf(files.files, 4)
assert.isTrue(files.encrypted)
assert.isFalse(files.headerEncrypted)
assert.deepEqual(parseSevenZipTail(fixture('header-encrypted.tail')), { files: [], encrypted: true, headerEncrypted: true })
})
test('a whole small archive; not the end of an archive: null', ({ assert }) => {
assert.deepEqual(parseSevenZipTail(fixture('tiny.7z'))?.files.map((f) => f.name), ['a.txt', 'b.nfo'])
// The end cut off, or data that isn't 7z at all.
assert.isNull(parseSevenZipTail(fixture('plain.tail').subarray(0, 30000)))
assert.isNull(parseSevenZipTail(fixture('sample.bin')))
})
})