@@ -97,6 +97,15 @@ 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 ;
108+
100109/// Incremental gzip inflater: feed compressed bytes with [`Self::push`]
101110/// and drain the plaintext produced so far; call [`Self::finish`] once the
102111/// compressed input ends to validate the gzip trailer (CRC-32 + ISIZE).
@@ -105,6 +114,11 @@ pub(crate) fn decoded_stream(
105114/// compiles to and runs under WASM. `flate2::write::GzDecoder` handles all
106115/// gzip framing: the 10-byte header, optional FNAME/FEXTRA/etc. fields
107116/// (buffered across `push` calls if split across chunks), and the trailer.
117+ ///
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.
108122struct GzipInflater {
109123 decoder : GzDecoder < Vec < u8 > > ,
110124}
@@ -116,14 +130,28 @@ impl GzipInflater {
116130 }
117131 }
118132
119- /// Feed one compressed chunk and return the plaintext bytes produced.
120- /// May return an empty `Vec` if `input` only completed part of the
121- /// gzip header or a DEFLATE block that hasn't emitted output yet.
122- fn push ( & mut self , input : & [ u8 ] ) -> Result < Vec < u8 > > {
123- self . decoder
124- . write_all ( input)
125- . map_err ( |e| Error :: from ( format ! ( "gzip decode failed: {e}" ) ) ) ?;
126- Ok ( std:: mem:: take ( self . decoder . get_mut ( ) ) )
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.
137+ ///
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+ }
153+ }
154+ Ok ( ( ) )
127155 }
128156
129157 /// Finish decompression, returning any trailing plaintext. Errors if
@@ -140,48 +168,58 @@ impl GzipInflater {
140168/// gunzipped plaintext chunks.
141169///
142170/// The returned stream inflates lazily: each poll pulls compressed chunks
143- /// from `inner` until it can emit at least one plaintext byte , so memory
171+ /// from `inner` until it can emit at least one plaintext run , so memory
144172/// use stays bounded by the chunk size rather than the whole body. A
145- /// decode error (malformed gzip) or a truncated stream surfaces as a
146- /// terminal `Err` item, after which the stream ends.
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.
147178pub ( crate ) fn gunzip < S > ( inner : S ) -> BodyStream
148179where
149180 S : Stream < Item = std:: result:: Result < Vec < u8 > , BodyError > > + Unpin + ' static ,
150181{
151182 struct DecodeState < S > {
152183 inner : S ,
153184 inflater : Option < GzipInflater > ,
185+ /// Plaintext runs decoded from the last compressed chunk but not
186+ /// yet emitted, drained one per poll (front to back).
187+ pending : std:: collections:: VecDeque < Vec < u8 > > ,
154188 done : bool ,
155189 }
156190
157191 Box :: pin ( futures_util:: stream:: unfold (
158192 DecodeState {
159193 inner,
160194 inflater : Some ( GzipInflater :: new ( ) ) ,
195+ pending : std:: collections:: VecDeque :: new ( ) ,
161196 done : false ,
162197 } ,
163198 |mut st| async move {
199+ if let Some ( run) = st. pending . pop_front ( ) {
200+ return Some ( ( Ok ( run) , st) ) ;
201+ }
164202 if st. done {
165203 return None ;
166204 }
167205 loop {
168206 match st. inner . next ( ) . await {
169207 Some ( Ok ( chunk) ) => {
170- let out = match st. inflater . as_mut ( ) . expect ( "inflater present" ) . push ( & chunk)
171- {
172- Ok ( out) => out,
173- Err ( e) => {
174- st. done = true ;
175- return Some ( ( Err ( BodyError :: Decode ( e. to_string ( ) ) ) , st) ) ;
176- }
177- } ;
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+ }
178214 // A chunk may not yet yield any plaintext (partial
179215 // header / block); pull more instead of emitting an
180- // empty item.
181- if out. is_empty ( ) {
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 {
182219 continue ;
183- }
184- return Some ( ( Ok ( out) , st) ) ;
220+ } ;
221+ st. pending = runs;
222+ return Some ( ( Ok ( first) , st) ) ;
185223 }
186224 Some ( Err ( e) ) => {
187225 st. done = true ;
@@ -291,6 +329,39 @@ mod tests {
291329 ) ;
292330 }
293331
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.
336+ #[ tokio:: test]
337+ async fn large_ratio_chunk_emits_bounded_runs ( ) {
338+ let plain = vec ! [ 0u8 ; INFLATE_STEP * 10 + 123 ] ;
339+ let compressed = gzip ( & plain) ;
340+ assert ! (
341+ compressed. len( ) < INFLATE_STEP ,
342+ "test payload should compress to well under one step"
343+ ) ;
344+ // Feed the whole compressed body as one chunk.
345+ let mut s = gunzip ( stream:: iter ( vec ! [ Ok :: <_, BodyError >( compressed) ] ) ) ;
346+ let mut total = 0usize ;
347+ let mut runs = 0usize ;
348+ while let Some ( item) = s. next ( ) . await {
349+ let run = item. unwrap ( ) ;
350+ assert ! (
351+ run. len( ) <= INFLATE_STEP ,
352+ "run of {} bytes exceeds INFLATE_STEP {INFLATE_STEP}" ,
353+ run. len( )
354+ ) ;
355+ total += run. len ( ) ;
356+ runs += 1 ;
357+ }
358+ assert_eq ! ( total, plain. len( ) ) ;
359+ assert ! (
360+ runs > 1 ,
361+ "a >step payload must span multiple runs, got {runs}"
362+ ) ;
363+ }
364+
294365 #[ tokio:: test]
295366 async fn upstream_error_propagates ( ) {
296367 let compressed = gzip ( b"partial" ) ;
0 commit comments