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:
1 parent
3b61215f6b
commit
d446bb57df
25 files changed
+4840
-11
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
@@ -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
@@ -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$"));
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseSevenZipTail, type SevenZipListing } from '#lib/sevenzip/parse'
|
||||
export { lzmaDecode, LzmaError } from '#lib/sevenzip/lzma'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { parseZipStart, type ZipStart } from '#lib/zip/parse'
|
||||
@@ -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
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+4017
File diff suppressed because it is too large.
Load diff
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Binary file not shown.
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user