Skip to main content

nntp_proxy/
compression.rs

1//! Compression support for wire compression (RFC 8054)
2//!
3//! This module provides the `DecompressStream` wrapper type used by
4//! `ConnectionStream::CompressedPlain` and `ConnectionStream::CompressedTls`.
5//!
6//! Implements bidirectional deflate compression using raw DEFLATE (no zlib header)
7//! as specified in RFC 8054 §2.2.2.
8
9use std::fmt;
10use std::io;
11use std::pin::Pin;
12use std::task::{Context, Poll, ready};
13use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
14
15const COMPRESSED_BUF_SIZE: usize = 8192;
16const WRITE_BUF_SIZE: usize = 16384; // Pre-allocated for poll_write compressed output
17const DEFAULT_COMPRESS_LEVEL: u32 = 1; // Fast compression (latency > ratio for a proxy)
18
19fn counter_delta_to_usize(before: u64, after: u64) -> usize {
20    let delta = after.saturating_sub(before);
21    usize::try_from(delta).expect("flate2 byte counter delta is bounded by the caller buffer size")
22}
23
24/// Tracks whether a pre-allocated output buffer has pending data to drain.
25#[derive(Debug, Clone, Copy)]
26enum DrainState {
27    Idle,
28    Draining { pos: usize, len: usize },
29}
30
31/// Try to write pending bytes from `buf` to inner based on `state`.
32/// Returns Ready(Ok(())) when fully drained or idle, Pending if inner isn't ready.
33fn poll_drain_buf<S: AsyncWrite>(
34    mut inner: Pin<&mut S>,
35    cx: &mut Context<'_>,
36    buf: &[u8],
37    state: &mut DrainState,
38) -> Poll<io::Result<()>> {
39    let DrainState::Draining { pos, len } = state else {
40        return Poll::Ready(Ok(()));
41    };
42    while *pos < *len {
43        let n = ready!(inner.as_mut().poll_write(cx, &buf[*pos..*len]))?;
44        if n == 0 {
45            return Poll::Ready(Err(io::Error::new(
46                io::ErrorKind::WriteZero,
47                "inner stream write returned 0",
48            )));
49        }
50        *pos += n;
51    }
52    *state = DrainState::Idle;
53    Poll::Ready(Ok(()))
54}
55
56/// Drain the compressor with the given flush mode, writing all output to the inner stream.
57fn poll_compress_drain<S: AsyncWrite>(
58    compressor: &mut flate2::Compress,
59    mut inner: Pin<&mut S>,
60    cx: &mut Context<'_>,
61    buf: &mut [u8],
62    state: &mut DrainState,
63    flush: flate2::FlushCompress,
64) -> Poll<io::Result<()>> {
65    loop {
66        ready!(poll_drain_buf(inner.as_mut(), cx, buf, state))?;
67
68        let before_out = compressor.total_out();
69        compressor
70            .compress(&[], buf, flush)
71            .map_err(io::Error::other)?;
72        let produced = counter_delta_to_usize(before_out, compressor.total_out());
73
74        if produced > 0 {
75            *state = DrainState::Draining {
76                pos: 0,
77                len: produced,
78            };
79        }
80
81        if produced < buf.len() {
82            ready!(poll_drain_buf(inner.as_mut(), cx, buf, state))?;
83            return Poll::Ready(Ok(()));
84        }
85    }
86}
87
88/// Decompress `input` into `buf`, returning `(consumed, done)`.
89/// `done` is true if output was produced or the stream ended.
90fn try_decompress(
91    decompressor: &mut flate2::Decompress,
92    input: &[u8],
93    buf: &mut ReadBuf<'_>,
94    stats: &mut u64,
95) -> io::Result<(usize, bool)> {
96    let out_slice = buf.initialize_unfilled();
97    if out_slice.is_empty() {
98        return Ok((0, true));
99    }
100
101    let before_in = decompressor.total_in();
102    let before_out = decompressor.total_out();
103
104    let decompress_status = decompressor
105        .decompress(input, out_slice, flate2::FlushDecompress::None)
106        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
107
108    let consumed = counter_delta_to_usize(before_in, decompressor.total_in());
109    let produced = counter_delta_to_usize(before_out, decompressor.total_out());
110
111    if produced > 0 {
112        *stats += produced as u64;
113        buf.advance(produced);
114    }
115
116    Ok((
117        consumed,
118        produced > 0 || matches!(decompress_status, flate2::Status::StreamEnd),
119    ))
120}
121
122pin_project_lite::pin_project! {
123    /// Bidirectional deflate stream wrapper for RFC 8054 COMPRESS DEFLATE.
124    ///
125    /// Reads: decompresses data from the inner stream (network → decompress → caller).
126    /// Writes: compresses data to the inner stream (caller → compress → network).
127    pub struct DecompressStream<S> {
128        #[pin]
129        inner: S,
130        // Read side: network → decompress → caller
131        decompressor: flate2::Decompress,
132        compressed_buf: Box<[u8]>,
133        compressed_pos: usize,
134        compressed_len: usize,
135        // Write side: caller → compress → network
136        compressor: flate2::Compress,
137        write_buf: Box<[u8]>,
138        flush_buf: Box<[u8]>,
139        write_drain: DrainState,
140        flush_drain: DrainState,
141        // Stats
142        bytes_compressed_in: u64,
143        bytes_decompressed_out: u64,
144    }
145}
146
147impl<S> DecompressStream<S> {
148    /// Wrap a stream for bidirectional deflate compression with the default (fast) level.
149    pub fn new(inner: S) -> Self {
150        Self::with_level(inner, DEFAULT_COMPRESS_LEVEL)
151    }
152
153    /// Wrap a stream with a specific compression level (0-9).
154    pub fn with_level(inner: S, level: u32) -> Self {
155        let level = flate2::Compression::new(level.min(9));
156        Self {
157            inner,
158            // Raw deflate, no zlib header (RFC 8054 §2.2.2)
159            decompressor: flate2::Decompress::new(false),
160            compressed_buf: vec![0u8; COMPRESSED_BUF_SIZE].into_boxed_slice(),
161            compressed_pos: 0,
162            compressed_len: 0,
163            compressor: flate2::Compress::new(level, false),
164            write_buf: vec![0u8; WRITE_BUF_SIZE].into_boxed_slice(),
165            flush_buf: vec![0u8; WRITE_BUF_SIZE].into_boxed_slice(),
166            write_drain: DrainState::Idle,
167            flush_drain: DrainState::Idle,
168            bytes_compressed_in: 0,
169            bytes_decompressed_out: 0,
170        }
171    }
172
173    /// Consume the wrapper, returning the inner stream.
174    pub fn into_inner(self) -> S {
175        self.inner
176    }
177
178    /// Get a reference to the inner stream.
179    #[inline]
180    pub const fn get_ref(&self) -> &S {
181        &self.inner
182    }
183
184    /// Get a mutable reference to the inner stream.
185    #[inline]
186    pub const fn get_mut(&mut self) -> &mut S {
187        &mut self.inner
188    }
189
190    /// Get bandwidth stats: (compressed bytes read from network, decompressed bytes delivered).
191    #[inline]
192    pub const fn bandwidth_stats(&self) -> (u64, u64) {
193        (self.bytes_compressed_in, self.bytes_decompressed_out)
194    }
195}
196
197impl<S: fmt::Debug> fmt::Debug for DecompressStream<S> {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.debug_struct("DecompressStream")
200            .field("inner", &self.inner)
201            .field("compressed_pos", &self.compressed_pos)
202            .field("compressed_len", &self.compressed_len)
203            .field("bytes_compressed_in", &self.bytes_compressed_in)
204            .field("bytes_decompressed_out", &self.bytes_decompressed_out)
205            .finish_non_exhaustive()
206    }
207}
208
209impl<S: AsyncRead> AsyncRead for DecompressStream<S> {
210    fn poll_read(
211        self: Pin<&mut Self>,
212        cx: &mut Context<'_>,
213        buf: &mut ReadBuf<'_>,
214    ) -> Poll<io::Result<()>> {
215        let mut this = self.project();
216        loop {
217            if *this.compressed_pos < *this.compressed_len {
218                let input = &this.compressed_buf[*this.compressed_pos..*this.compressed_len];
219                let (consumed, done) =
220                    try_decompress(this.decompressor, input, buf, this.bytes_decompressed_out)?;
221                *this.compressed_pos += consumed;
222                if done {
223                    return Poll::Ready(Ok(()));
224                }
225                continue;
226            }
227
228            let (_, done) =
229                try_decompress(this.decompressor, &[], buf, this.bytes_decompressed_out)?;
230            if done {
231                return Poll::Ready(Ok(()));
232            }
233
234            // Inline poll_fill_buffer (can't call &mut self methods from projection)
235            *this.compressed_pos = 0;
236            *this.compressed_len = 0;
237            let mut read_buf = ReadBuf::new(this.compressed_buf);
238            ready!(this.inner.as_mut().poll_read(cx, &mut read_buf))?;
239            let n = read_buf.filled().len();
240            if n == 0 {
241                return Poll::Ready(Ok(()));
242            }
243            *this.compressed_len = n;
244            *this.bytes_compressed_in += n as u64;
245        }
246    }
247}
248
249impl<S: AsyncWrite> AsyncWrite for DecompressStream<S> {
250    fn poll_write(
251        self: Pin<&mut Self>,
252        cx: &mut Context<'_>,
253        buf: &[u8],
254    ) -> Poll<io::Result<usize>> {
255        let mut this = self.project();
256
257        ready!(poll_drain_buf(
258            this.inner.as_mut(),
259            cx,
260            this.write_buf,
261            this.write_drain,
262        ))?;
263
264        let before_in = this.compressor.total_in();
265        let before_out = this.compressor.total_out();
266
267        this.compressor
268            .compress(buf, this.write_buf, flate2::FlushCompress::None)
269            .map_err(io::Error::other)?;
270
271        let consumed = counter_delta_to_usize(before_in, this.compressor.total_in());
272        let produced = counter_delta_to_usize(before_out, this.compressor.total_out());
273
274        if produced > 0 {
275            *this.write_drain = DrainState::Draining {
276                pos: 0,
277                len: produced,
278            };
279            match poll_drain_buf(this.inner.as_mut(), cx, this.write_buf, this.write_drain) {
280                Poll::Ready(Ok(())) | Poll::Pending => {}
281                Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
282            }
283        }
284
285        Poll::Ready(Ok(if consumed > 0 {
286            consumed
287        } else {
288            buf.len().min(1)
289        }))
290    }
291
292    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
293        let mut this = self.project();
294        ready!(poll_drain_buf(
295            this.inner.as_mut(),
296            cx,
297            this.write_buf,
298            this.write_drain
299        ))?;
300        ready!(poll_compress_drain(
301            this.compressor,
302            this.inner.as_mut(),
303            cx,
304            this.flush_buf,
305            this.flush_drain,
306            flate2::FlushCompress::Sync,
307        ))?;
308        this.inner.poll_flush(cx)
309    }
310
311    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
312        let mut this = self.project();
313        ready!(poll_drain_buf(
314            this.inner.as_mut(),
315            cx,
316            this.write_buf,
317            this.write_drain
318        ))?;
319        ready!(poll_compress_drain(
320            this.compressor,
321            this.inner.as_mut(),
322            cx,
323            this.flush_buf,
324            this.flush_drain,
325            flate2::FlushCompress::Finish,
326        ))?;
327        this.inner.poll_shutdown(cx)
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use tokio::io::{AsyncReadExt, AsyncWriteExt};
335
336    fn deflate_compress(data: &[u8]) -> Vec<u8> {
337        use flate2::write::DeflateEncoder;
338        use std::io::Write;
339        let mut enc = DeflateEncoder::new(Vec::new(), flate2::Compression::default());
340        enc.write_all(data).unwrap();
341        enc.finish().unwrap()
342    }
343
344    async fn feed_compressed(
345        data: &[u8],
346    ) -> (
347        tokio::io::DuplexStream,
348        DecompressStream<tokio::io::DuplexStream>,
349    ) {
350        let compressed = deflate_compress(data);
351        let (mut tx, rx) = tokio::io::duplex(4096);
352        tx.write_all(&compressed).await.unwrap();
353        tx.shutdown().await.unwrap();
354        (tx, DecompressStream::new(rx))
355    }
356
357    #[tokio::test]
358    async fn test_roundtrip_read() {
359        let original = b"Hello, NNTP world! COMPRESS DEFLATE test data.\r\n";
360        let (_tx, mut stream) = feed_compressed(original).await;
361
362        let mut output = Vec::new();
363        stream.read_to_end(&mut output).await.unwrap();
364
365        assert_eq!(output, original);
366    }
367
368    #[tokio::test]
369    async fn test_roundtrip_write_flush() {
370        // Write plaintext to DecompressStream, read compressed output, decompress, verify
371        use flate2::read::DeflateDecoder;
372        use std::io::Read;
373
374        let original = b"GROUP alt.test\r\n";
375
376        let (reader, writer_side) = tokio::io::duplex(4096);
377        let mut stream = DecompressStream::new(writer_side);
378
379        stream.write_all(original).await.unwrap();
380        stream.flush().await.unwrap();
381        stream.shutdown().await.unwrap();
382
383        drop(stream);
384
385        // Read all compressed output from the other end
386        let mut compressed_output = Vec::new();
387        let mut reader = reader;
388        reader.read_to_end(&mut compressed_output).await.unwrap();
389
390        // Decompress and verify
391        let mut decoder = DeflateDecoder::new(&compressed_output[..]);
392        let mut decompressed = Vec::new();
393        decoder.read_to_end(&mut decompressed).unwrap();
394
395        assert_eq!(decompressed, original);
396    }
397
398    #[tokio::test]
399    async fn test_large_data_roundtrip() {
400        // 1MB of data
401        let mut original = Vec::with_capacity(1024 * 1024);
402        for i in 0..1024 * 64 {
403            original.extend_from_slice(
404                format!("Line {i}: Some NNTP article content here\r\n").as_bytes(),
405            );
406        }
407
408        let compressed = deflate_compress(&original);
409
410        let (mut writer, reader) = tokio::io::duplex(16384);
411
412        tokio::spawn(async move {
413            writer.write_all(&compressed).await.unwrap();
414            writer.shutdown().await.unwrap();
415        });
416
417        let mut stream = DecompressStream::new(reader);
418        let mut output = Vec::new();
419        stream.read_to_end(&mut output).await.unwrap();
420
421        assert_eq!(output.len(), original.len());
422        assert_eq!(output, original);
423    }
424
425    #[tokio::test]
426    async fn test_bandwidth_stats() {
427        let original = b"Test data for bandwidth tracking\r\n";
428        let (_tx, mut stream) = feed_compressed(original).await;
429
430        let mut output = Vec::new();
431        stream.read_to_end(&mut output).await.unwrap();
432
433        let (compressed_in, decompressed_out) = stream.bandwidth_stats();
434        assert!(compressed_in > 0);
435        assert_eq!(decompressed_out, original.len() as u64);
436    }
437
438    #[test]
439    fn test_debug_impl() {
440        let stream = DecompressStream::new(std::io::Cursor::new(Vec::<u8>::new()));
441        let debug_str = format!("{stream:?}");
442        assert!(debug_str.contains("DecompressStream"));
443        assert!(debug_str.contains("compressed_pos"));
444        assert!(debug_str.contains("bytes_compressed_in"));
445    }
446}