streaming RAR extractor jsr.io/@mary/rar
jsr
0

Configure Feed

Select the types of activity you want to include in your feed.

pkg-rar / lib / rar3 / block.ts
25 kB 784 lines
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';