@@ -97,28 +97,33 @@ pub(crate) fn decoded_stream(
9797 Ok ( stream)
9898}
9999
100- /// Largest compressed slice fed to the decoder per step, and the point at
101- /// which accumulated plaintext is drained. DEFLATE can inflate a small
102- /// input by a large factor (a hostile "zip bomb" reaches ~1000x) , so a
103- /// single unbounded `write_all` of a whole chunk could balloon the sink
104- /// `Vec` far past the isolate's memory ceiling. Feeding the decoder in
105- /// bounded slices and draining between them keeps the plaintext held at
106- /// once proportional to this window, not to the compression ratio .
107- const INFLATE_STEP : usize = 64 * 1024 ;
100+ /// Compressed bytes fed to the decoder per [`GzipInflater:: step`] call.
101+ /// `write_all` inflates its whole slice into the decoder's sink before
102+ /// returning, and DEFLATE reaches ~1032x , so a step holds up to
103+ /// `INFLATE_INPUT_STEP * 1032` bytes of plaintext transiently (~4 MiB
104+ /// here). A small input step keeps that under the isolate's ~128 MB
105+ /// ceiling even for a zip bomb; a larger chunk or the whole body is never
106+ /// handed to `write_all` at once .
107+ const INFLATE_INPUT_STEP : usize = 4 * 1024 ;
108108
109- /// Incremental gzip inflater: feed compressed bytes with [`Self::push`]
110- /// and drain the plaintext produced so far; call [`Self::finish`] once the
111- /// compressed input ends to validate the gzip trailer (CRC-32 + ISIZE).
109+ /// Largest plaintext run emitted downstream. The plaintext drained after a
110+ /// step is split into runs no larger than this.
111+ const EMIT_STEP : usize = 64 * 1024 ;
112+
113+ /// Incremental gzip inflater: feed a bounded compressed slice with
114+ /// [`Self::step`], draining the plaintext it produced; call
115+ /// [`Self::finish`] once the compressed input ends to validate the gzip
116+ /// trailer (CRC-32 + ISIZE).
112117///
113118/// Backed by [`flate2`]'s pure-Rust `rust_backend` (`miniz_oxide`), so it
114119/// compiles to and runs under WASM. `flate2::write::GzDecoder` handles all
115120/// gzip framing: the 10-byte header, optional FNAME/FEXTRA/etc. fields
116- /// (buffered across `push` calls if split across chunks ), and the trailer.
121+ /// (buffered across calls if split across steps ), and the trailer.
117122///
118- /// [`Self::push`] feeds the decoder at most [`INFLATE_STEP `] compressed
119- /// bytes at a time and drains after each step, so the sink never holds
120- /// more than one step's worth of inflated output regardless of how large
121- /// or compressible the caller's chunk is .
123+ /// The caller (see [`gunzip`]) feeds at most [`INFLATE_INPUT_STEP `] bytes
124+ /// per [`Self::step`] and stops once a step yields plaintext, leaving the
125+ /// rest of the compressed chunk unfed. At most one step is inflated ahead
126+ /// of what has been emitted .
122127struct GzipInflater {
123128 decoder : GzDecoder < Vec < u8 > > ,
124129}
@@ -130,26 +135,24 @@ impl GzipInflater {
130135 }
131136 }
132137
133- /// Feed one compressed chunk, invoking `emit` with each bounded
134- /// plaintext run produced. `emit` may be called zero times (the input
135- /// only advanced the gzip header or a not-yet-emitting DEFLATE block),
136- /// once, or many times for a highly compressible chunk.
138+ /// Feed one compressed slice (at most [`INFLATE_INPUT_STEP`] bytes),
139+ /// invoking `emit` with each [`EMIT_STEP`]-bounded plaintext run the
140+ /// step produced. `emit` may be called zero times (the slice only
141+ /// advanced the gzip header or a not-yet-emitting DEFLATE block), once,
142+ /// or several times for a highly compressible slice.
137143 ///
138- /// Input is written to the decoder one [`INFLATE_STEP`] slice at a
139- /// time, draining the sink after each, and every drained run is further
140- /// split into at most [`INFLATE_STEP`]-byte emissions. So no single
141- /// emitted run (what flows downstream) exceeds one step regardless of
142- /// the (attacker-controlled) compression ratio, and the sink is drained
143- /// per input step instead of accumulating the whole chunk's output.
144- fn push ( & mut self , input : & [ u8 ] , mut emit : impl FnMut ( Vec < u8 > ) ) -> Result < ( ) > {
145- for slice in input. chunks ( INFLATE_STEP ) {
146- self . decoder
147- . write_all ( slice)
148- . map_err ( |e| Error :: from ( format ! ( "gzip decode failed: {e}" ) ) ) ?;
149- let produced = std:: mem:: take ( self . decoder . get_mut ( ) ) ;
150- for run in produced. chunks ( INFLATE_STEP ) {
151- emit ( run. to_vec ( ) ) ;
152- }
144+ /// # Panics
145+ ///
146+ /// Debug-asserts `slice.len() <= INFLATE_INPUT_STEP`; a larger slice
147+ /// breaks the transient-plaintext bound.
148+ fn step ( & mut self , slice : & [ u8 ] , mut emit : impl FnMut ( Vec < u8 > ) ) -> Result < ( ) > {
149+ debug_assert ! ( slice. len( ) <= INFLATE_INPUT_STEP , "input slice too large" ) ;
150+ self . decoder
151+ . write_all ( slice)
152+ . map_err ( |e| Error :: from ( format ! ( "gzip decode failed: {e}" ) ) ) ?;
153+ let produced = std:: mem:: take ( self . decoder . get_mut ( ) ) ;
154+ for run in produced. chunks ( EMIT_STEP ) {
155+ emit ( run. to_vec ( ) ) ;
153156 }
154157 Ok ( ( ) )
155158 }
@@ -167,23 +170,29 @@ impl GzipInflater {
167170/// Wrap a compressed body `Stream` in a decoding stream that yields the
168171/// gunzipped plaintext chunks.
169172///
170- /// The returned stream inflates lazily: each poll pulls compressed chunks
171- /// from `inner` until it can emit at least one plaintext run, so memory
172- /// use stays bounded by the chunk size rather than the whole body. A
173- /// single compressed chunk that inflates a lot is emitted as several
174- /// [`INFLATE_STEP`]-bounded runs, drained from `pending` across polls, so
175- /// a hostile compression ratio cannot force one giant allocation. A decode
176- /// error (malformed gzip) or a truncated stream surfaces as a terminal
177- /// `Err` item, after which the stream ends.
173+ /// Each poll feeds the decoder at most [`INFLATE_INPUT_STEP`] compressed
174+ /// bytes and stops once a step yields plaintext, leaving the rest of the
175+ /// current chunk unfed until the next poll (tracked by `current`/`pos`).
176+ /// At most one input step is inflated ahead of what has been emitted, so
177+ /// the transient plaintext is bounded by `INFLATE_INPUT_STEP * ratio`
178+ /// rather than the whole chunk's inflation. A step that produces more than
179+ /// [`EMIT_STEP`] of plaintext yields several bounded runs drained from
180+ /// `pending`. A decode error (malformed gzip) or a truncated stream
181+ /// surfaces as a terminal `Err` item, after which the stream ends.
178182pub ( crate ) fn gunzip < S > ( inner : S ) -> BodyStream
179183where
180184 S : Stream < Item = std:: result:: Result < Vec < u8 > , BodyError > > + Unpin + ' static ,
181185{
182186 struct DecodeState < S > {
183187 inner : S ,
184188 inflater : Option < GzipInflater > ,
185- /// Plaintext runs decoded from the last compressed chunk but not
186- /// yet emitted, drained one per poll (front to back).
189+ /// Compressed chunk currently being fed, and the offset of the
190+ /// next unfed byte. Bytes at `pos..` are not fed until the current
191+ /// runs drain, capping how far ahead the decoder inflates.
192+ current : Vec < u8 > ,
193+ pos : usize ,
194+ /// Plaintext runs from the last step not yet emitted, drained one
195+ /// per poll (front to back).
187196 pending : std:: collections:: VecDeque < Vec < u8 > > ,
188197 done : bool ,
189198 }
@@ -192,6 +201,8 @@ where
192201 DecodeState {
193202 inner,
194203 inflater : Some ( GzipInflater :: new ( ) ) ,
204+ current : Vec :: new ( ) ,
205+ pos : 0 ,
195206 pending : std:: collections:: VecDeque :: new ( ) ,
196207 done : false ,
197208 } ,
@@ -203,24 +214,30 @@ where
203214 return None ;
204215 }
205216 loop {
206- match st. inner . next ( ) . await {
207- Some ( Ok ( chunk) ) => {
208- let inflater = st. inflater . as_mut ( ) . expect ( "inflater present" ) ;
209- let mut runs = std:: collections:: VecDeque :: new ( ) ;
210- if let Err ( e) = inflater. push ( & chunk, |run| runs. push_back ( run) ) {
211- st. done = true ;
212- return Some ( ( Err ( BodyError :: Decode ( e. to_string ( ) ) ) , st) ) ;
213- }
214- // A chunk may not yet yield any plaintext (partial
215- // header / block); pull more instead of emitting an
216- // empty item. Otherwise emit the first run now and
217- // queue the rest for subsequent polls.
218- let Some ( first) = runs. pop_front ( ) else {
219- continue ;
220- } ;
217+ // Feed the unfed tail of the current chunk one bounded
218+ // step at a time, stopping as soon as a step emits.
219+ while st. pos < st. current . len ( ) {
220+ let end = ( st. pos + INFLATE_INPUT_STEP ) . min ( st. current . len ( ) ) ;
221+ let slice = st. current [ st. pos ..end] . to_vec ( ) ;
222+ st. pos = end;
223+ let inflater = st. inflater . as_mut ( ) . expect ( "inflater present" ) ;
224+ let mut runs = std:: collections:: VecDeque :: new ( ) ;
225+ if let Err ( e) = inflater. step ( & slice, |run| runs. push_back ( run) ) {
226+ st. done = true ;
227+ return Some ( ( Err ( BodyError :: Decode ( e. to_string ( ) ) ) , st) ) ;
228+ }
229+ if let Some ( first) = runs. pop_front ( ) {
221230 st. pending = runs;
222231 return Some ( ( Ok ( first) , st) ) ;
223232 }
233+ }
234+
235+ // Current chunk fully fed; pull the next compressed chunk.
236+ match st. inner . next ( ) . await {
237+ Some ( Ok ( chunk) ) => {
238+ st. current = chunk;
239+ st. pos = 0 ;
240+ }
224241 Some ( Err ( e) ) => {
225242 st. done = true ;
226243 return Some ( ( Err ( e) , st) ) ;
@@ -329,17 +346,16 @@ mod tests {
329346 ) ;
330347 }
331348
332- // A highly compressible payload whose plaintext far exceeds
333- // INFLATE_STEP, delivered as a single compressed chunk, must inflate
334- // to the exact original but be emitted as several bounded runs so no
335- // single allocation exceeds the step. This is the zip-bomb guard.
349+ // A compressible payload whose plaintext exceeds EMIT_STEP, delivered
350+ // as one compressed chunk, reconstructs and is emitted as
351+ // EMIT_STEP-bounded runs rather than a single unbounded one.
336352 #[ tokio:: test]
337353 async fn large_ratio_chunk_emits_bounded_runs ( ) {
338- let plain = vec ! [ 0u8 ; INFLATE_STEP * 10 + 123 ] ;
354+ let plain = vec ! [ 0u8 ; EMIT_STEP * 10 + 123 ] ;
339355 let compressed = gzip ( & plain) ;
340356 assert ! (
341- compressed. len( ) < INFLATE_STEP ,
342- "test payload should compress to well under one step"
357+ compressed. len( ) < EMIT_STEP ,
358+ "test payload should compress to well under one emit step"
343359 ) ;
344360 // Feed the whole compressed body as one chunk.
345361 let mut s = gunzip ( stream:: iter ( vec ! [ Ok :: <_, BodyError >( compressed) ] ) ) ;
@@ -348,8 +364,8 @@ mod tests {
348364 while let Some ( item) = s. next ( ) . await {
349365 let run = item. unwrap ( ) ;
350366 assert ! (
351- run. len( ) <= INFLATE_STEP ,
352- "run of {} bytes exceeds INFLATE_STEP {INFLATE_STEP }" ,
367+ run. len( ) <= EMIT_STEP ,
368+ "run of {} bytes exceeds EMIT_STEP {EMIT_STEP }" ,
353369 run. len( )
354370 ) ;
355371 total += run. len ( ) ;
@@ -362,6 +378,59 @@ mod tests {
362378 ) ;
363379 }
364380
381+ // Feeding INFLATE_INPUT_STEP-bounded slices (as gunzip does) round-trips
382+ // a body whose compressed form spans several steps.
383+ #[ test]
384+ fn inflater_step_bounds_input ( ) {
385+ // A poorly-compressible body whose compressed form spans several
386+ // input steps.
387+ let mut plain = Vec :: new ( ) ;
388+ for i in 0u32 ..0x1_0000 {
389+ plain. extend_from_slice ( & i. wrapping_mul ( 2_654_435_761 ) . to_le_bytes ( ) ) ;
390+ }
391+ let compressed = gzip ( & plain) ;
392+ assert ! (
393+ compressed. len( ) > INFLATE_INPUT_STEP ,
394+ "test needs a body spanning multiple input steps"
395+ ) ;
396+
397+ let mut inflater = GzipInflater :: new ( ) ;
398+ let mut total = 0usize ;
399+ // Feed as gunzip does: INFLATE_INPUT_STEP-bounded slices.
400+ for slice in compressed. chunks ( INFLATE_INPUT_STEP ) {
401+ inflater
402+ . step ( slice, |run| {
403+ assert ! ( run. len( ) <= EMIT_STEP , "run exceeds EMIT_STEP" ) ;
404+ total += run. len ( ) ;
405+ } )
406+ . unwrap ( ) ;
407+ }
408+ total += inflater. finish ( ) . unwrap ( ) . len ( ) ;
409+ assert_eq ! ( total, plain. len( ) ) ;
410+ }
411+
412+ // One compressed chunk whose inflation exceeds a step: the first poll
413+ // returns an EMIT_STEP-bounded run, leaving the rest unfed, and the
414+ // full stream reconstructs the original.
415+ #[ tokio:: test]
416+ async fn large_ratio_chunk_emits_before_full_feed ( ) {
417+ let plain = vec ! [ 0u8 ; EMIT_STEP * 8 ] ;
418+ let compressed = gzip ( & plain) ;
419+ let mut s = gunzip ( stream:: iter ( vec ! [ Ok :: <_, BodyError >( compressed) ] ) ) ;
420+ let first = s. next ( ) . await . expect ( "a run" ) . expect ( "no error" ) ;
421+ assert ! (
422+ first. len( ) <= EMIT_STEP ,
423+ "first emitted run must be bounded, got {}" ,
424+ first. len( )
425+ ) ;
426+ // The remaining runs complete the original.
427+ let mut total = first. len ( ) ;
428+ while let Some ( item) = s. next ( ) . await {
429+ total += item. unwrap ( ) . len ( ) ;
430+ }
431+ assert_eq ! ( total, plain. len( ) ) ;
432+ }
433+
365434 #[ tokio:: test]
366435 async fn upstream_error_propagates ( ) {
367436 let compressed = gzip ( b"partial" ) ;
0 commit comments