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

+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