streaming RAR extractor
jsr.io/@mary/rar
jsr
1/**
2 * @module
3 *
4 * parser for the RAR1-4 ("RAR3") on-disk block format. each block opens with
5 * a 7-byte common header (CRC16 + type + flags + headerSize), optionally
6 * followed by a 4-byte `add_size` when the LONG_BLOCK flag is set, then a
7 * type-specific payload. data follows immediately after the header bytes.
8 */
9
10import { crc32 } from '@mary/crc32';
11
12import {
13 ChecksumMismatchError,
14 InvalidSignatureError,
15 MalformedArchiveError,
16 TruncatedArchiveError,
17} from '../errors.ts';
18import { BlockFlag, BlockType, EndArcFlag, FileFlag, MainFlag, RAR3_SIGNATURE } from './constants.ts';
19
20// #region cursor
21
22// a small little-endian reader over a header buffer. RAR3 headers are short
23// and rarely span more than a few dozen bytes, so a hand-rolled cursor reads
24// cleanly without pulling in DataView.
25class Cursor {
26 readonly data: Uint8Array;
27 pos: number;
28
29 constructor(data: Uint8Array, pos: number = 0) {
30 this.data = data;
31 this.pos = pos;
32 }
33
34 get remaining(): number {
35 return this.data.length - this.pos;
36 }
37
38 u8(): number {
39 return this.data[this.pos++];
40 }
41
42 u16(): number {
43 const value = this.data[this.pos] | (this.data[this.pos + 1] << 8);
44 this.pos += 2;
45 return value;
46 }
47
48 u32(): number {
49 const value = (this.data[this.pos] |
50 (this.data[this.pos + 1] << 8) |
51 (this.data[this.pos + 2] << 16) |
52 (this.data[this.pos + 3] << 24)) >>> 0;
53 this.pos += 4;
54 return value;
55 }
56
57 bytes(length: number): Uint8Array {
58 const value = this.data.subarray(this.pos, this.pos + length);
59 this.pos += length;
60 return value;
61 }
62
63 skip(length: number): void {
64 this.pos += length;
65 }
66}
67
68// #endregion
69
70// #region types
71
72/** a DOS-format timestamp split into its 6 calendar fields. */
73export interface DosTime {
74 year: number;
75 month: number;
76 day: number;
77 hour: number;
78 minute: number;
79 second: number;
80}
81
82/** fields common to every parsed RAR3 block. */
83export interface BlockCommon {
84 /** CRC16 stored in the header */
85 headerCrc: number;
86 /** total on-disk size of the header, including the CRC, type, flags, and size fields */
87 headerSize: number;
88 /** absolute offset of the block within the archive */
89 headerOffset: number;
90 /** absolute offset of the block's data area, immediately after the header */
91 dataOffset: number;
92 /** common header flag bits */
93 blockFlags: number;
94 /** size of the data area, or 0 when no data follows */
95 dataSize: number;
96}
97
98/** the archive mark block — the 7-byte signature at the start of each volume. */
99export interface MarkBlock extends BlockCommon {
100 kind: 'mark';
101}
102
103/** the main archive header. */
104export interface MainBlock extends BlockCommon {
105 kind: 'main';
106 /** archive flag bits, re-exposed as `mainFlags` for symmetry with `block.blockFlags` */
107 mainFlags: number;
108 /** legacy archive-level comment subblock, when {@link MainFlag.comment} is set */
109 oldComment?: OldCommentSubBlock;
110}
111
112/**
113 * a legacy comment subblock embedded in the trailing area of a MAIN or FILE
114 * header. RAR1.5 and RAR2.x stored comments inline this way rather than as
115 * a top-level SUB block. the data area is either stored verbatim
116 * ({@link OldCommentMethod.stored}) or compressed with the v15/v20 LZSS
117 * encoder, depending on `method`.
118 */
119export interface OldCommentSubBlock {
120 /** decompressed length declared in the subblock's mini header */
121 decompressedSize: number;
122 /** version stamp (15 = RAR1.5, 20 = RAR2.0+) */
123 version: number;
124 /** compression method (0x30 stored, 0x33-0x35 v15/v20 LZSS) */
125 method: number;
126 /** CRC16 of the decompressed data */
127 dataCrc: number;
128 /** raw on-disk bytes: stored or compressed depending on `method` */
129 data: Uint8Array;
130}
131
132/** a single high-precision timestamp parsed from the extended-time record. */
133export interface ExtTime {
134 /** the base DOS time, possibly bumped by 1 second per the spec's +sec flag */
135 time: DosTime;
136 /** sub-second remainder in nanoseconds, in the range [0, 1e9) */
137 nanos: number;
138}
139
140/** a file or sub-record entry. */
141export interface FileBlock extends BlockCommon {
142 kind: 'file' | 'sub';
143 /** file flag bits */
144 fileFlags: number;
145 /** uncompressed size in bytes */
146 unpackedSize: number;
147 /** host OS code */
148 hostOs: number;
149 /** data CRC32 */
150 crc: number;
151 /** modification time, decoded from the DOS-format field */
152 mtime: DosTime;
153 /** modification time with sub-second precision, when the EXTTIME flag is set */
154 extMtime?: ExtTime;
155 /** creation time, when an extended-time record contributes one */
156 ctime?: ExtTime;
157 /** access time, when an extended-time record contributes one */
158 atime?: ExtTime;
159 /** archival time, when an extended-time record contributes one */
160 arctime?: ExtTime;
161 /** required minimum unpack version (20-29 for RAR2.0 through RAR4) */
162 extractVersion: number;
163 /** compression method code (0x30 stored, 0x33 normal, 0x35 best, etc.) */
164 method: number;
165 /** decoded entry name; non-unicode names are decoded as latin-1 */
166 name: string;
167 /** raw, undecoded name bytes */
168 rawName: Uint8Array;
169 /** OS-specific attributes */
170 attributes: number;
171 /** 8-byte per-file salt, when the SALT flag is set */
172 salt?: Uint8Array;
173 /** legacy file-level comment subblock, when {@link FileFlag.comment} is set */
174 oldComment?: OldCommentSubBlock;
175}
176
177/** the end-of-archive marker. */
178export interface EndArcBlock extends BlockCommon {
179 kind: 'endArc';
180 endArcFlags: number;
181 /** archive-wide data CRC, when {@link EndArcFlag.dataCrc} is set */
182 dataCrc?: number;
183 /** the volume number this marker closes, when {@link EndArcFlag.volNr} is set */
184 volumeNumber?: number;
185}
186
187/** a block whose type byte is not yet handled by the parser. */
188export interface UnknownBlock extends BlockCommon {
189 kind: 'unknown';
190 blockType: number;
191}
192
193/** any parsed RAR3 block. */
194export type Rar3Block = EndArcBlock | FileBlock | MainBlock | MarkBlock | UnknownBlock;
195
196// #endregion
197
198// #region helpers
199
200// walks the trailing-subblock region of a MAIN or FILE header looking for an
201// OLD_COMMENT (0x75) record. each subblock opens with the 7-byte common
202// block header (CRC16 + type + flags + size), and oldComment additionally
203// carries a 6-byte mini header (declen + ver + meth + dataCrc) before its
204// data area. the parent block's CRC excludes the trailing region, so we
205// don't verify the subblock CRC16; a failed decompression downstream is the
206// signal that the bytes are corrupt.
207const findOldCommentSubblock = (region: Uint8Array): OldCommentSubBlock | undefined => {
208 const COMMON = 7;
209 const COMMENT_HDR = 6;
210
211 let pos = 0;
212 while (pos + COMMON <= region.length) {
213 const subType = region[pos + 2];
214 const subSize = region[pos + 5] | (region[pos + 6] << 8);
215 if (subSize < COMMON || pos + subSize > region.length) {
216 return undefined;
217 }
218
219 if (subType === BlockType.oldComment && pos + COMMON + COMMENT_HDR <= pos + subSize) {
220 const hdrPos = pos + COMMON;
221 const decompressedSize = region[hdrPos] | (region[hdrPos + 1] << 8);
222 const version = region[hdrPos + 2];
223 const method = region[hdrPos + 3];
224 const dataCrc = region[hdrPos + 4] | (region[hdrPos + 5] << 8);
225 const data = region.subarray(hdrPos + COMMENT_HDR, pos + subSize);
226 return { data, dataCrc, decompressedSize, method, version };
227 }
228
229 pos += subSize;
230 }
231 return undefined;
232};
233
234const decodeDosTime = (raw: number): DosTime => {
235 return {
236 day: (raw >>> 16) & 0x1f,
237 hour: (raw >>> 11) & 0x1f,
238 minute: (raw >>> 5) & 0x3f,
239 month: (raw >>> 21) & 0x0f,
240 second: (raw & 0x1f) * 2,
241 year: ((raw >>> 25) & 0x7f) + 1980,
242 };
243};
244
245// rarfile's UnicodeFilename decoder: RAR3 stores unicode names either as
246// straight UTF-8 (UNICODE flag, no NUL) or as a NUL-separated base + a
247// compressed-unicode tail that describes high-byte overrides for each
248// character in the base. when the tail is unparseable we fall back to UTF-8
249// of the base — matching rarfile's `_decode` fallback chain — and finally to
250// latin-1 as a last resort so a totally malformed name still round-trips.
251const decodeUnicodeName = (raw: Uint8Array): string => {
252 const nul = raw.indexOf(0);
253 if (nul === -1) {
254 return safeUtf8Decode(raw);
255 }
256
257 const base = raw.subarray(0, nul);
258 const tail = raw.subarray(nul + 1);
259 const decoded = tail.length > 0 ? decodeUnicodeTail(base, tail) : undefined;
260 return decoded ?? safeUtf8Decode(base);
261};
262
263// decodes `bytes` as UTF-8, falling back to latin-1 if the bytes contain a
264// sequence the decoder rejects.
265const safeUtf8Decode = (bytes: Uint8Array): string => {
266 try {
267 return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
268 } catch {
269 return latin1Decode(bytes);
270 }
271};
272
273// decodes the compressed-unicode tail produced by RAR2.x/3.x; based on
274// rarfile's UnicodeFilename. returns undefined if the tail is malformed.
275const decodeUnicodeTail = (base: Uint8Array, tail: Uint8Array): string | undefined => {
276 if (tail.length < 1) {
277 return undefined;
278 }
279
280 const highByte = tail[0] << 8;
281 let inPos = 1;
282 let outPos = 0;
283 let flagBits = 0;
284 let flagByte = 0;
285
286 // each emitted value is a UTF-16 code unit; the input encodes them as
287 // (low, high) byte pairs, and the result is a UTF-16 little-endian string.
288 const out: number[] = [];
289
290 while (inPos < tail.length) {
291 if (flagBits === 0) {
292 flagByte = tail[inPos++];
293 flagBits = 8;
294 }
295 flagBits -= 2;
296 const cmd = (flagByte >>> flagBits) & 3;
297
298 switch (cmd) {
299 case 0: {
300 // low byte from the tail, high byte is zero
301 if (inPos >= tail.length) return undefined;
302 out.push(tail[inPos++]);
303 outPos++;
304 break;
305 }
306 case 1: {
307 // low byte from the tail, high byte is the shared `hi`
308 if (inPos >= tail.length) return undefined;
309 out.push(highByte | tail[inPos++]);
310 outPos++;
311 break;
312 }
313 case 2: {
314 // full 16-bit value: low byte then high byte
315 if (inPos + 1 >= tail.length) return undefined;
316 out.push(tail[inPos] | (tail[inPos + 1] << 8));
317 inPos += 2;
318 outPos++;
319 break;
320 }
321 case 3: {
322 if (inPos >= tail.length) return undefined;
323 const length = tail[inPos++];
324 if (length & 0x80) {
325 // run of `(length & 0x7f) + 2` code units copied from the base
326 // name with a single correction byte added and `hi` overlaid.
327 if (inPos >= tail.length) return undefined;
328 const correction = tail[inPos++];
329 for (let idx = 0; idx < (length & 0x7f) + 2; idx++) {
330 if (outPos >= base.length) return undefined;
331 out.push(highByte | ((base[outPos] + correction) & 0xff));
332 outPos++;
333 }
334 } else {
335 // run of `length + 2` code units copied from the base name
336 // verbatim with high byte zero — used for ASCII spans.
337 for (let idx = 0; idx < length + 2; idx++) {
338 if (outPos >= base.length) return undefined;
339 out.push(base[outPos]);
340 outPos++;
341 }
342 }
343 break;
344 }
345 }
346 }
347
348 // `out` is a sequence of UTF-16 code units; `fromCharCode` interprets each
349 // argument as one such unit, so a surrogate pair encoded as two consecutive
350 // code units becomes a single supplementary-plane character in the string.
351 return String.fromCharCode(...out);
352};
353
354const latin1Decode = (bytes: Uint8Array): string => {
355 let out = '';
356 for (const byte of bytes) {
357 out += String.fromCharCode(byte);
358 }
359 return out;
360};
361
362// #endregion
363
364// #region block parsing
365
366const COMMON_HEADER_SIZE = 7;
367
368const parseFileLikeBlock = (
369 kind: 'file' | 'sub',
370 common: BlockCommon,
371 cursor: Cursor,
372): FileBlock => {
373 const fileFlags = common.blockFlags;
374
375 // the file-header's compress_size field overlays the common header's
376 // add_size field — they are the same four on-disk bytes — so callers must
377 // position `cursor` at the start of that shared field. the resulting value
378 // also becomes the block's `dataSize`.
379 let packedSize = cursor.u32();
380 let unpackedSize = cursor.u32();
381 const hostOs = cursor.u8();
382 const crc = cursor.u32();
383 const mtime = decodeDosTime(cursor.u32());
384 const extractVersion = cursor.u8();
385 const method = cursor.u8();
386 const nameSize = cursor.u16();
387 const attributes = cursor.u32();
388
389 if ((fileFlags & FileFlag.large) !== 0) {
390 // 64-bit sizes: the spec extends both fields with their high halves.
391 // JavaScript numbers safely hold the lower 53 bits of the resulting
392 // quantity, plenty for any real archive.
393 const packedHigh = cursor.u32();
394 const unpackedHigh = cursor.u32();
395 packedSize += packedHigh * 0x100000000;
396 unpackedSize += unpackedHigh * 0x100000000;
397 }
398
399 const rawName = cursor.bytes(nameSize);
400 // names without the UNICODE flag are stored in either UTF-8 or the host
401 // OS's 8-bit charset; rarfile tries UTF-8 first and falls back to the
402 // default charset, and we do the same with latin-1 as the safety net.
403 const name = (fileFlags & FileFlag.unicode) !== 0 ? decodeUnicodeName(rawName) : safeUtf8Decode(rawName);
404
405 let salt: Uint8Array | undefined;
406 if ((fileFlags & FileFlag.salt) !== 0) {
407 salt = cursor.bytes(8);
408 }
409
410 const block: FileBlock = {
411 ...common,
412 attributes,
413 crc,
414 dataSize: packedSize,
415 extractVersion,
416 fileFlags,
417 hostOs,
418 kind,
419 method,
420 mtime,
421 name,
422 rawName,
423 salt,
424 unpackedSize,
425 };
426
427 if ((fileFlags & FileFlag.extTime) !== 0 && cursor.remaining >= 2) {
428 parseExtTime(block, cursor);
429 }
430
431 if ((fileFlags & FileFlag.comment) !== 0) {
432 // the cursor now sits at the trailing subblock area, past every fixed
433 // field including the extended-time record.
434 const region = cursor.data.subarray(cursor.pos, common.headerSize);
435 const oldComment = findOldCommentSubblock(region);
436 if (oldComment !== undefined) {
437 block.oldComment = oldComment;
438 }
439 }
440
441 return block;
442};
443
444// parses the optional extended-time record that follows the name (and any
445// per-file salt) when the EXTTIME flag is set. the record carries up to four
446// timestamps — mtime, ctime, atime, arctime — each with sub-second precision
447// and an optional whole-second correction.
448const parseExtTime = (block: FileBlock, cursor: Cursor): void => {
449 const flags = cursor.u16();
450
451 const mtimeNibble = (flags >>> 12) & 0x0f;
452 const ctimeNibble = (flags >>> 8) & 0x0f;
453 const atimeNibble = (flags >>> 4) & 0x0f;
454 const arctimeNibble = flags & 0x0f;
455
456 const ext = parseXTime(mtimeNibble, cursor, block.mtime);
457 if (ext !== undefined) {
458 block.extMtime = ext;
459 }
460 const ctime = parseXTime(ctimeNibble, cursor);
461 if (ctime !== undefined) {
462 block.ctime = ctime;
463 }
464 const atime = parseXTime(atimeNibble, cursor);
465 if (atime !== undefined) {
466 block.atime = atime;
467 }
468 const arctime = parseXTime(arctimeNibble, cursor);
469 if (arctime !== undefined) {
470 block.arctime = arctime;
471 }
472};
473
474// one extended timestamp nibble: bit 3 marks "present", bit 2 marks "+1
475// second" (to recover the half-second DOS time loses), bits 1-0 are the
476// count of fractional bytes (0-3), each contributing 8 bits of 100-ns ticks.
477const parseXTime = (nibble: number, cursor: Cursor, base?: DosTime): ExtTime | undefined => {
478 if ((nibble & 8) === 0) {
479 return undefined;
480 }
481
482 let time = base;
483 if (time === undefined) {
484 if (cursor.remaining < 4) {
485 return undefined;
486 }
487 time = decodeDosTime(cursor.u32());
488 }
489
490 const fracBytes = nibble & 3;
491 if (cursor.remaining < fracBytes) {
492 return undefined;
493 }
494
495 let rem = 0;
496 for (let idx = 0; idx < fracBytes; idx++) {
497 rem = (cursor.u8() << 16) | (rem >>> 8);
498 }
499
500 if ((nibble & 4) !== 0 && time.second < 59) {
501 time = { ...time, second: time.second + 1 };
502 }
503
504 // each unit of `rem` is 100 ns, with the value packed so the high bytes
505 // already represent the most significant fraction; multiply to nanoseconds.
506 return { nanos: rem * 100, time };
507};
508
509const parseMainBlock = (common: BlockCommon, cursor: Cursor, headerSize: number): MainBlock => {
510 // the 6 reserved bytes (`high_pos_av`, `pos_av`) sit right after the common
511 // header; ENCRYPTVER adds one more before the trailing subblock area.
512 cursor.skip(6);
513 if ((common.blockFlags & MainFlag.encryptVer) !== 0) {
514 cursor.skip(1);
515 }
516
517 const block: MainBlock = { ...common, kind: 'main', mainFlags: common.blockFlags };
518 if ((common.blockFlags & MainFlag.comment) !== 0) {
519 const region = cursor.data.subarray(cursor.pos, headerSize);
520 const oldComment = findOldCommentSubblock(region);
521 if (oldComment !== undefined) {
522 block.oldComment = oldComment;
523 }
524 }
525 return block;
526};
527
528const parseEndArcBlock = (common: BlockCommon, cursor: Cursor): EndArcBlock => {
529 const endArcFlags = common.blockFlags;
530
531 let dataCrc: number | undefined;
532 if ((endArcFlags & EndArcFlag.dataCrc) !== 0 && cursor.remaining >= 4) {
533 dataCrc = cursor.u32();
534 }
535
536 let volumeNumber: number | undefined;
537 if ((endArcFlags & EndArcFlag.volNr) !== 0 && cursor.remaining >= 2) {
538 volumeNumber = cursor.u16();
539 }
540
541 return { ...common, dataCrc, endArcFlags, kind: 'endArc', volumeNumber };
542};
543
544/**
545 * parses one RAR3 block from a buffer big enough to hold its full header and
546 * its `add_size` u32 (i.e. the bytes from `headerOffset` through the end of
547 * the cleartext header).
548 *
549 * the 2-byte header CRC is verified after parsing. its coverage range varies
550 * by block type: main and file headers exclude their trailing comment
551 * sub-block area, sub and endArc cover the whole header. the legacy
552 * oldSub (0x77) block additionally extends CRC coverage over its data area;
553 * none of our fixtures carry one, so the verification is skipped there.
554 *
555 * @param header the complete header bytes
556 * @param headerOffset absolute offset of the header within the archive
557 * @returns the parsed block
558 * @throws if the header is too small for the declared `headerSize`, or its
559 * stored CRC does not match the bytes it covers
560 */
561export const parseBlock = (header: Uint8Array, headerOffset: number): Rar3Block => {
562 if (header.length < COMMON_HEADER_SIZE) {
563 throw new TruncatedArchiveError(`RAR3 block header truncated: ${header.length} bytes`);
564 }
565
566 const cursor = new Cursor(header);
567 const headerCrc = cursor.u16();
568 const blockType = cursor.u8();
569 const blockFlags = cursor.u16();
570 const headerSize = cursor.u16();
571
572 if (blockType === BlockType.mark) {
573 // the 7-byte signature presents as a self-describing block; just stamp
574 // it as the mark and let the walker advance past it.
575 return {
576 blockFlags,
577 dataOffset: headerOffset + COMMON_HEADER_SIZE,
578 dataSize: 0,
579 headerCrc,
580 headerOffset,
581 headerSize: COMMON_HEADER_SIZE,
582 kind: 'mark',
583 };
584 }
585
586 if (header.length < headerSize) {
587 throw new TruncatedArchiveError(
588 `RAR3 block header truncated: declared ${headerSize} bytes, have ${header.length}`,
589 );
590 }
591
592 const longBlock = (blockFlags & BlockFlag.longBlock) !== 0;
593 const isFileLike = blockType === BlockType.file || blockType === BlockType.sub;
594
595 // for file/sub blocks the LONG_BLOCK add_size field shares its on-disk
596 // bytes with the file-header's compress_size, so we don't advance past it
597 // here — parseFileLikeBlock will read those four bytes itself. for every
598 // other block kind that sets LONG_BLOCK, the add_size is a standalone
599 // data-area length we consume up front.
600 const dataSize = longBlock && !isFileLike ? cursor.u32() : 0;
601 const dataOffset = headerOffset + headerSize;
602
603 const common: BlockCommon = {
604 blockFlags,
605 dataOffset,
606 dataSize,
607 headerCrc,
608 headerOffset,
609 headerSize,
610 };
611
612 let block: Rar3Block;
613 switch (blockType) {
614 case BlockType.main: {
615 block = parseMainBlock(common, cursor, headerSize);
616 break;
617 }
618 case BlockType.file: {
619 block = parseFileLikeBlock('file', common, cursor);
620 break;
621 }
622 case BlockType.sub: {
623 block = parseFileLikeBlock('sub', common, cursor);
624 break;
625 }
626 case BlockType.endArc: {
627 block = parseEndArcBlock(common, cursor);
628 break;
629 }
630 default: {
631 block = { ...common, blockType, kind: 'unknown' };
632 }
633 }
634
635 verifyHeaderCrc(header, blockType, blockFlags, longBlock, cursor.pos, headerSize, headerOffset);
636 return block;
637};
638
639// computes how many bytes from the start of the header the CRC covers, then
640// checks the stored CRC16 against `crc32(coverage) & 0xffff`. coverage rules
641// follow rarfile's parser: MAIN goes through the fixed header (6 reserved
642// bytes plus 1 for ENCRYPTVER) but excludes any trailing comment subblock;
643// FILE/SUB stop at the end of the parsed file-header fields (which already
644// excludes the optional comment subblock that follows when FileFlag.comment
645// is set); other block kinds cover the entire declared header.
646const verifyHeaderCrc = (
647 header: Uint8Array,
648 blockType: number,
649 blockFlags: number,
650 longBlock: boolean,
651 cursorEnd: number,
652 headerSize: number,
653 headerOffset: number,
654): void => {
655 // the legacy oldSub block (RAR1.5) folds its data area into the CRC range
656 // and we never see one in the corpus; skip the check rather than fail
657 // loudly on a hypothetical archive that carries one.
658 if (blockType === BlockType.oldSub) {
659 return;
660 }
661
662 let crcEnd: number;
663 if (blockType === BlockType.main) {
664 crcEnd = COMMON_HEADER_SIZE + (longBlock ? 4 : 0) + 6;
665 if ((blockFlags & MainFlag.encryptVer) !== 0) {
666 crcEnd += 1;
667 }
668 } else if (blockType === BlockType.file) {
669 // FILE crc_pos excludes the trailing comment subblock area, so it ends
670 // where the parsed file-header fields stop (matching rarfile's
671 // `_parse_file_header` return value).
672 crcEnd = cursorEnd;
673 } else {
674 // SUB and the rest (endArc, unknown, legacy) cover the whole declared
675 // header. SUB blocks carry per-record fields after the file-header
676 // section (owner UID/GID, ACL bytes, etc.) that the generic file parse
677 // doesn't enumerate; CRC always runs over the declared headerSize.
678 crcEnd = headerSize;
679 }
680
681 const storedCrc = header[0] | (header[1] << 8);
682 const actualCrc = crc32(header.subarray(2, crcEnd)) & 0xffff;
683 if (actualCrc !== storedCrc) {
684 throw new ChecksumMismatchError(
685 `RAR3 header CRC mismatch:` +
686 ` stored 0x${storedCrc.toString(16).padStart(4, '0')},` +
687 ` computed 0x${actualCrc.toString(16).padStart(4, '0')}` +
688 ` (at 0x${headerOffset.toString(16)}, type=0x${blockType.toString(16)},` +
689 ` range=2..${crcEnd})`,
690 );
691 }
692};
693
694// #endregion
695
696// #region archive walking
697
698/**
699 * reports whether the given offset of `data` is the start of a RAR3 signature.
700 * @param data archive bytes
701 * @param offset position to inspect
702 * @returns true when the next bytes match {@link RAR3_SIGNATURE}
703 */
704export const matchesSignature = (data: Uint8Array, offset: number): boolean => {
705 for (let idx = 0; idx < RAR3_SIGNATURE.length; idx++) {
706 if (data[offset + idx] !== RAR3_SIGNATURE[idx]) {
707 return false;
708 }
709 }
710 return true;
711};
712
713/**
714 * locates the RAR3 signature within a buffer.
715 *
716 * the signature is searched for rather than assumed at offset 0, so archives
717 * carrying a self-extracting stub are handled.
718 *
719 * @param data archive bytes
720 * @returns the offset of the signature, or -1 if none is found
721 */
722export const findSignature = (data: Uint8Array): number => {
723 for (let offset = 0, end = data.length - RAR3_SIGNATURE.length; offset <= end; offset++) {
724 if (matchesSignature(data, offset)) {
725 return offset;
726 }
727 }
728 return -1;
729};
730
731/**
732 * walks every block of a RAR3 archive held entirely in memory.
733 *
734 * the signature block is yielded first as a `kind: 'mark'` block, then each
735 * following header until an end-of-archive marker or the buffer is exhausted.
736 *
737 * @param data the complete archive bytes
738 * @returns an iterator over the parsed blocks
739 * @throws if no signature is found or a block header fails CRC validation
740 */
741export function* walkBlocks(data: Uint8Array): Generator<Rar3Block> {
742 const start = findSignature(data);
743 if (start === -1) {
744 throw new InvalidSignatureError(`not a RAR3 archive: signature not found`);
745 }
746
747 let offset = start;
748
749 // peek `headerSize` enough bytes to slice the header; the LONG_BLOCK
750 // add_size field, when present, sits inside the 11-byte window we cover by
751 // reading the declared header size.
752 while (offset + COMMON_HEADER_SIZE <= data.length) {
753 // peek the declared header size, then slice that many bytes for the
754 // parser; the file/sub-block overlay means headerSize is the full
755 // on-disk header length for every kind we know about.
756 const headerSize = data[offset + 5] | (data[offset + 6] << 8);
757 if (headerSize < COMMON_HEADER_SIZE) {
758 throw new MalformedArchiveError(
759 `invalid RAR3 header size: ${headerSize} (at 0x${offset.toString(16)})`,
760 );
761 }
762 if (offset + headerSize > data.length) {
763 throw new TruncatedArchiveError(
764 `RAR3 block truncated: declares ${headerSize} bytes,` +
765 ` ${data.length - offset} available (at 0x${offset.toString(16)})`,
766 );
767 }
768
769 const block = parseBlock(data.subarray(offset, offset + headerSize), offset);
770 yield block;
771
772 if (block.kind === 'endArc') {
773 break;
774 }
775
776 offset = block.dataOffset + block.dataSize;
777 }
778}
779
780// #endregion
781
782// re-export the most-used flag namespace so the parser is comfortable to
783// import from a single module.
784export { BlockFlag, BlockType, EndArcFlag, FileFlag, MainFlag } from './constants.ts';