Skip to content
File

Blob: src/worker/git/pack/indexer/inflateCursor.ts

typescript337 lines
1/**
2 * Byte-accounting inflate wrapper and CRC-32 helpers for the streaming pack indexer.
3 *
4 * Why pako instead of the Web DecompressionStream:
5 * Git pack entries are concatenated zlib streams. The indexer must know exactly how
6 * many compressed bytes each entry consumed so it can compute span boundaries and
7 * CRC-32 values. The Web DecompressionStream API does not expose byte-position
8 * accounting; pako's Inflate class in raw mode exposes `strm.total_in` which gives
9 * us the precise count.
10 *
11 * Implementation note: pako's zlib-mode Inflate has a known limitation with
12 * trailing bytes after Z_STREAM_END. We work around this by using raw mode
13 * (`raw: true`) and manually accounting for the 2-byte zlib header and 4-byte
14 * Adler-32 checksum that wrap the raw deflate data in each pack entry.
15 */
16 
17import { Inflate } from "pako";
18 
19// ---------------------------------------------------------------------------
20// CRC-32 (ISO 3309 / ITU-T V.42, polynomial 0xEDB88320)
21// ---------------------------------------------------------------------------
22 
23const CRC32_TABLE = new Uint32Array(256);
24for (let i = 0; i < 256; i++) {
25 let c = i;
26 for (let j = 0; j < 8; j++) {
27 c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
28 }
29 CRC32_TABLE[i] = c;
30}
31 
32/** Start value for a fresh CRC-32 accumulation. */
33export const CRC32_INIT = 0xffffffff;
34 
35/** Feed a byte range into a running CRC-32 value. */
36export function crc32Update(crc: number, data: Uint8Array, start = 0, end = data.length): number {
37 for (let i = start; i < end; i++) {
38 crc = CRC32_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
39 }
40 return crc;
41}
42 
43/** Finalize a running CRC-32 value to its unsigned 32-bit result. */
44export function crc32Finish(crc: number): number {
45 return (crc ^ 0xffffffff) >>> 0;
46}
47 
48// ---------------------------------------------------------------------------
49// Adler-32 (RFC 1950 zlib wrapper trailer)
50// ---------------------------------------------------------------------------
51 
52const ADLER32_MOD = 65521;
53const ADLER32_INIT = 1;
54 
55function adler32Update(adler: number, data: Uint8Array, start = 0, end = data.length): number {
56 let a = adler & 0xffff;
57 let b = (adler >>> 16) & 0xffff;
58 for (let i = start; i < end; i++) {
59 a += data[i];
60 if (a >= ADLER32_MOD) a -= ADLER32_MOD;
61 b += a;
62 if (b >= ADLER32_MOD) b -= ADLER32_MOD;
63 }
64 return ((b & 0xffff) << 16) | (a & 0xffff);
65}
66 
67// ---------------------------------------------------------------------------
68// Zlib header helpers
69// ---------------------------------------------------------------------------
70 
71/** Size of the zlib (RFC 1950) header: CMF + FLG bytes. */
72const ZLIB_HEADER_SIZE = 2;
73 
74/** Size of the zlib (RFC 1950) Adler-32 trailer. */
75const ZLIB_TRAILER_SIZE = 4;
76 
77/**
78 * Validate a zlib header (CMF + FLG). Returns true if valid.
79 * Per RFC 1950: (CMF * 256 + FLG) % 31 === 0, CM must be 8 (deflate).
80 */
81function isValidZlibHeader(cmf: number, flg: number): boolean {
82 if ((cmf & 0x0f) !== 8) return false; // CM must be deflate
83 return (cmf * 256 + flg) % 31 === 0;
84}
85 
86// ---------------------------------------------------------------------------
87// InflateCursor – pako wrapper with byte accounting
88// ---------------------------------------------------------------------------
89 
90/**
91 * InflateCursor wraps a pako Inflate instance in raw mode and tracks the exact
92 * number of compressed bytes consumed from the input (including the zlib
93 * header and trailer). After each `push()` call the caller can inspect the
94 * confirmed consumed-byte count and carry-forward tail for the current pack
95 * entry.
96 *
97 * The zlib header (2 bytes) is consumed on the first push. The raw deflate
98 * data is processed by pako. The Adler-32 trailer (4 bytes) is accounted
99 * for after the deflate stream ends.
100 */
101export class InflateCursor {
102 private inf!: Inflate;
103 private chunks: Uint8Array[] = [];
104 private totalOutputBytes = 0;
105 private capturedOutputBytes = 0;
106 private captureLimit = Number.MAX_SAFE_INTEGER;
107 private outputTruncated = false;
108 private rawDeflateDone = false;
109 private entryDone = false;
110 private adler32 = ADLER32_INIT;
111 private trailerBytes = new Uint8Array(ZLIB_TRAILER_SIZE);
112 
113 /** Whether the 2-byte zlib header has been consumed from the input. */
114 private headerConsumed = false;
115 /** How many Adler-32 trailer bytes have been consumed so far. */
116 private trailerBytesConsumed = 0;
117 /** Bytes confirmed consumed from the most recent push(). */
118 private lastBytesConsumed = 0;
119 /** Tail from the most recent push() that belongs to the next pack entry. */
120 private lastUnconsumedInput: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
121 
122 constructor() {
123 this.createInflate();
124 }
125 
126 private createInflate(): void {
127 // Use raw mode to avoid pako's zlib-mode trailing-byte bug.
128 // We handle the zlib header/trailer manually so the scanner can both
129 // account for exact consumed bytes and validate the zlib wrapper itself.
130 const inf = new Inflate({ raw: true });
131 inf.onData = (chunk: Uint8Array) => {
132 this.adler32 = adler32Update(this.adler32, chunk, 0, chunk.length);
133 this.totalOutputBytes += chunk.length;
134 const captureRemaining = this.captureLimit - this.capturedOutputBytes;
135 if (captureRemaining <= 0) {
136 this.outputTruncated = this.outputTruncated || chunk.length > 0;
137 return;
138 }
139 if (chunk.length <= captureRemaining) {
140 this.chunks.push(chunk);
141 this.capturedOutputBytes += chunk.length;
142 return;
143 }
144 this.chunks.push(chunk.slice(0, captureRemaining));
145 this.capturedOutputBytes += captureRemaining;
146 this.outputTruncated = true;
147 };
148 inf.onEnd = (_status: number) => {
149 // Handled via err check in push().
150 };
151 this.inf = inf;
152 }
153 
154 /**
155 * Feed compressed bytes into the inflate engine.
156 * After this call, check `finished` and `consumedInputBytes`.
157 *
158 * The first call must include at least the 2-byte zlib header. The cursor
159 * strips the header and feeds only the raw deflate data to pako.
160 */
161 push(data: Uint8Array): void {
162 if (this.entryDone) {
163 this.lastBytesConsumed = 0;
164 this.lastUnconsumedInput = data;
165 return;
166 }
167 
168 let pos = 0;
169 let consumed = 0;
170 
171 // On the first push, skip the 2-byte zlib header.
172 if (!this.headerConsumed) {
173 if (data.length < ZLIB_HEADER_SIZE) {
174 throw new Error("inflate: buffer too small for zlib header");
175 }
176 if (!isValidZlibHeader(data[0], data[1])) {
177 throw new Error(
178 `inflate: invalid zlib header (CMF=0x${data[0].toString(16)}, FLG=0x${data[1].toString(16)})`
179 );
180 }
181 if (data[1] & 0x20) {
182 // Git packs never use preset dictionaries. In raw mode pako would
183 // otherwise treat the FDICT bit as just more wrapper bytes, so we
184 // reject it explicitly here before any payload bytes are accepted.
185 throw new Error("inflate: zlib preset dictionaries are not supported");
186 }
187 pos = ZLIB_HEADER_SIZE;
188 consumed = ZLIB_HEADER_SIZE;
189 this.headerConsumed = true;
190 }
191 
192 if (!this.rawDeflateDone && pos < data.length) {
193 // Push only the raw deflate bytes to pako. We track the trailing Adler
194 // bytes separately because pako reports raw-stream completion before the
195 // zlib wrapper has necessarily been fully consumed from this chunk.
196 // `ended === true` here only means "raw deflate is done", not "the pack
197 // entry is done". The scanner must keep feeding bytes until the wrapper's
198 // 4-byte trailer has been accounted for too.
199 const rawInput = data.subarray(pos);
200 // pako does not expose stable public byte-accounting APIs for this use
201 // case, so the scanner intentionally reaches into `strm.total_in` and
202 // `ended`. Keep that footgun documented here because a pako upgrade could
203 // break the indexer even if TypeScript still compiles.
204 const strm = (this.inf as unknown as { strm: { total_in: number } }).strm;
205 const beforeTotalIn = strm.total_in;
206 this.inf.push(rawInput, false);
207 if ((this.inf.err as number) !== 0) {
208 throw new Error(`inflate error ${this.inf.err}: ${this.inf.msg}`);
209 }
210 const rawConsumed = strm.total_in - beforeTotalIn;
211 pos += rawConsumed;
212 consumed += rawConsumed;
213 if ((this.inf as unknown as { ended: boolean }).ended) {
214 this.rawDeflateDone = true;
215 }
216 }
217 
218 if (this.rawDeflateDone && pos < data.length) {
219 const trailerNeeded = ZLIB_TRAILER_SIZE - this.trailerBytesConsumed;
220 const trailerBytes = Math.min(trailerNeeded, data.length - pos);
221 this.trailerBytes.set(data.subarray(pos, pos + trailerBytes), this.trailerBytesConsumed);
222 this.trailerBytesConsumed += trailerBytes;
223 pos += trailerBytes;
224 consumed += trailerBytes;
225 if (this.trailerBytesConsumed === ZLIB_TRAILER_SIZE) {
226 const dv = new DataView(
227 this.trailerBytes.buffer,
228 this.trailerBytes.byteOffset,
229 this.trailerBytes.byteLength
230 );
231 const expectedAdler32 = dv.getUint32(0, false);
232 if (expectedAdler32 !== this.adler32 >>> 0) {
233 throw new Error(
234 `inflate: Adler-32 mismatch (expected 0x${expectedAdler32.toString(16)}, got 0x${(this.adler32 >>> 0).toString(16)})`
235 );
236 }
237 this.entryDone = true;
238 }
239 }
240 
241 this.lastBytesConsumed = consumed;
242 // Anything left here belongs to the next pack entry. We only expose that
243 // tail after consuming the current entry's trailer bytes.
244 this.lastUnconsumedInput = pos >= data.length ? new Uint8Array(0) : data.subarray(pos);
245 }
246 
247 /**
248 * Total compressed input bytes consumed by this zlib stream, including the
249 * 2-byte zlib header and 4-byte Adler-32 trailer.
250 */
251 get bytesConsumed(): number {
252 const strm = (this.inf as unknown as { strm: { total_in: number } }).strm;
253 return (this.headerConsumed ? ZLIB_HEADER_SIZE : 0) + strm.total_in + this.trailerBytesConsumed;
254 }
255 
256 /** Bytes confirmed consumed from the most recent push(). */
257 get consumedInputBytes(): number {
258 return this.lastBytesConsumed;
259 }
260 
261 /** Whether the zlib stream has fully completed (deflate done + trailer accounted). */
262 get finished(): boolean {
263 return this.entryDone;
264 }
265 
266 /** The concatenated decompressed output. Collapses intermediate chunks on first access. */
267 get output(): Uint8Array {
268 if (this.outputTruncated) {
269 throw new Error("inflate: full output was not retained for this entry");
270 }
271 if (this.chunks.length === 1) return this.chunks[0];
272 const out = new Uint8Array(this.totalOutputBytes);
273 let pos = 0;
274 for (const c of this.chunks) {
275 out.set(c, pos);
276 pos += c.length;
277 }
278 // Replace chunks with single concatenated result to release intermediate references.
279 this.chunks = [out];
280 return out;
281 }
282 
283 /**
284 * Returns the retained output prefix. This may be shorter than the full
285 * inflated payload when the caller only needs the delta header prefix.
286 */
287 get capturedOutput(): Uint8Array {
288 if (this.chunks.length === 0) return new Uint8Array(0);
289 if (this.chunks.length === 1) return this.chunks[0];
290 const out = new Uint8Array(this.capturedOutputBytes);
291 let pos = 0;
292 for (const c of this.chunks) {
293 out.set(c, pos);
294 pos += c.length;
295 }
296 this.chunks = [out];
297 return out;
298 }
299 
300 /** Total decompressed output bytes seen for the current entry. */
301 get outputLength(): number {
302 return this.totalOutputBytes;
303 }
304 
305 /**
306 * Returns the bytes from the last push() that were not consumed by the
307 * inflate engine (because they belong to the *next* pack entry). The
308 * caller should carry these forward as the prefix of the next entry.
309 *
310 * When the inflate is finished, this accounts for the 4-byte Adler-32
311 * trailer that follows the raw deflate data.
312 */
313 get unconsumedInput(): Uint8Array<ArrayBufferLike> {
314 return this.lastUnconsumedInput;
315 }
316 
317 /** Reset for the next pack entry. Releases the previous pako instance. */
318 reset(options?: { captureLimit?: number }): void {
319 // Release the old pako Inflate instance's internal state before creating a new one.
320 (this.inf as unknown) = null;
321 this.createInflate();
322 this.chunks.length = 0;
323 this.totalOutputBytes = 0;
324 this.capturedOutputBytes = 0;
325 this.captureLimit = options?.captureLimit ?? Number.MAX_SAFE_INTEGER;
326 this.outputTruncated = false;
327 this.rawDeflateDone = false;
328 this.entryDone = false;
329 this.adler32 = ADLER32_INIT;
330 this.trailerBytes.fill(0);
331 this.headerConsumed = false;
332 this.trailerBytesConsumed = 0;
333 this.lastBytesConsumed = 0;
334 this.lastUnconsumedInput = new Uint8Array(0);
335 }
336}