Skip to main content

codecs/decoding/
decompression.rs

1//! Decompression of complete message payloads before framing and deserializing.
2//!
3//! Some producers compress each message payload at the application level (as opposed to
4//! transport-level compression, which is handled transparently by the client library or protocol
5//! layer). Sources that receive complete message payloads (e.g. `kafka`) can use
6//! [`DecompressionConfig`] to decompress each payload before it enters the framing / decoding
7//! pipeline.
8
9use std::{
10    fmt::Debug,
11    io::{self, Cursor},
12    path::PathBuf,
13    sync::Arc,
14};
15
16use vector_common::decompression::{CappedDecoder, DecoderDictionary};
17use vector_config::configurable_component;
18
19/// Algorithm used to decompress message payloads.
20#[configurable_component]
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22#[serde(rename_all = "lowercase")]
23pub enum DecompressionAlgorithm {
24    /// [Gzip][gzip] decompression.
25    ///
26    /// [gzip]: https://www.gzip.org/
27    Gzip,
28
29    /// [Zlib][zlib] decompression.
30    ///
31    /// [zlib]: https://zlib.net/
32    Zlib,
33
34    /// [Zstandard][zstd] decompression.
35    ///
36    /// [zstd]: https://facebook.github.io/zstd/
37    Zstd,
38}
39
40impl DecompressionAlgorithm {
41    const fn as_str(self) -> &'static str {
42        match self {
43            Self::Gzip => "gzip",
44            Self::Zlib => "zlib",
45            Self::Zstd => "zstd",
46        }
47    }
48}
49
50/// Configuration for decompressing message payloads.
51///
52/// This applies to compression performed by the producer on each message payload, before the
53/// payload was sent. Transport-level compression is handled by the protocol layer and does not
54/// require this option.
55///
56/// Payloads are decompressed before framing and decoding are applied.
57#[configurable_component]
58#[derive(Clone, Debug)]
59pub struct DecompressionConfig {
60    /// The decompression algorithm.
61    pub algorithm: DecompressionAlgorithm,
62
63    /// The path to a compression dictionary to use when decompressing payloads.
64    ///
65    /// The dictionary must be the same as the one used by the producer when compressing the
66    /// payloads. Only supported with the `zstd` algorithm.
67    #[serde(default, skip_serializing_if = "vector_core::serde::is_default")]
68    #[configurable(metadata(docs::examples = "/etc/vector/compression.dict"))]
69    pub dictionary_path: Option<PathBuf>,
70}
71
72impl DecompressionConfig {
73    /// Builds a [`Decompressor`] from this configuration.
74    ///
75    /// The dictionary, if configured, is read and prepared once here so that per-payload
76    /// decompression can reference it cheaply.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if a dictionary is configured with a non-`zstd` algorithm, or if the
81    /// dictionary file cannot be read.
82    pub fn build(&self) -> vector_common::Result<Decompressor> {
83        let dictionary = match (&self.dictionary_path, self.algorithm) {
84            (None, _) => None,
85            (Some(path), DecompressionAlgorithm::Zstd) => {
86                let contents = std::fs::read(path).map_err(|error| {
87                    format!(
88                        "Failed to read decompression dictionary file {}: {error}.",
89                        path.display()
90                    )
91                })?;
92                Some(Arc::new(DecoderDictionary::copy(&contents)))
93            }
94            (Some(_), algorithm) => {
95                return Err(format!(
96                    "`dictionary_path` is not supported with the `{}` algorithm, only `zstd`.",
97                    algorithm.as_str()
98                )
99                .into());
100            }
101        };
102
103        Ok(Decompressor {
104            algorithm: self.algorithm,
105            dictionary,
106        })
107    }
108}
109
110/// Decompresses complete message payloads, built from a [`DecompressionConfig`].
111#[derive(Clone)]
112pub struct Decompressor {
113    algorithm: DecompressionAlgorithm,
114    dictionary: Option<Arc<DecoderDictionary<'static>>>,
115}
116
117impl Debug for Decompressor {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("Decompressor")
120            .field("algorithm", &self.algorithm)
121            .field("dictionary", &self.dictionary.as_ref().map(|_| "<dict>"))
122            .finish()
123    }
124}
125
126impl Decompressor {
127    /// Decompresses a complete message payload.
128    ///
129    /// The decompressed size is bounded by the globally configured cap (see
130    /// `vector_common::decompression`), so a malformed or malicious payload cannot drive
131    /// unbounded memory growth.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the payload is not valid for the configured algorithm (including
136    /// dictionary mismatches) or if the decompressed size exceeds the configured cap.
137    pub fn decompress(&self, payload: &[u8]) -> io::Result<Vec<u8>> {
138        let reader = Cursor::new(payload);
139        match (self.algorithm, &self.dictionary) {
140            (DecompressionAlgorithm::Gzip, _) => CappedDecoder::gzip(reader).decompress(),
141            (DecompressionAlgorithm::Zlib, _) => CappedDecoder::zlib(reader).decompress(),
142            (DecompressionAlgorithm::Zstd, None) => CappedDecoder::zstd(reader)?.decompress(),
143            (DecompressionAlgorithm::Zstd, Some(dictionary)) => {
144                CappedDecoder::zstd_with_dictionary(reader, dictionary)?.decompress()
145            }
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use std::io::Write;
153
154    use flate2::write::{GzEncoder, ZlibEncoder};
155
156    use super::*;
157
158    fn gzip_compress(data: &[u8]) -> Vec<u8> {
159        let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default());
160        encoder.write_all(data).unwrap();
161        encoder.finish().unwrap()
162    }
163
164    fn zlib_compress(data: &[u8]) -> Vec<u8> {
165        let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
166        encoder.write_all(data).unwrap();
167        encoder.finish().unwrap()
168    }
169
170    fn train_dictionary() -> Vec<u8> {
171        let samples: Vec<Vec<u8>> = (0..100)
172            .map(|i| format!(r#"{{"id":{i},"message":"sample event {i}"}}"#).into_bytes())
173            .collect();
174        zstd::dict::from_samples(&samples, 4 * 1024).expect("dictionary training failed")
175    }
176
177    fn write_temp_dictionary(contents: &[u8]) -> tempfile::NamedTempFile {
178        let file = tempfile::NamedTempFile::new().unwrap();
179        std::fs::write(file.path(), contents).unwrap();
180        file
181    }
182
183    fn config(
184        algorithm: DecompressionAlgorithm,
185        dictionary_path: Option<PathBuf>,
186    ) -> DecompressionConfig {
187        DecompressionConfig {
188            algorithm,
189            dictionary_path,
190        }
191    }
192
193    #[test]
194    fn deserializes_config() {
195        let config: DecompressionConfig = toml::from_str(
196            r#"
197            algorithm = "zstd"
198            dictionary_path = "/etc/vector/compression.dict"
199            "#,
200        )
201        .unwrap();
202        assert_eq!(config.algorithm, DecompressionAlgorithm::Zstd);
203        assert_eq!(
204            config.dictionary_path,
205            Some(PathBuf::from("/etc/vector/compression.dict"))
206        );
207
208        let config: DecompressionConfig = toml::from_str(r#"algorithm = "gzip""#).unwrap();
209        assert_eq!(config.algorithm, DecompressionAlgorithm::Gzip);
210        assert_eq!(config.dictionary_path, None);
211    }
212
213    #[test]
214    fn gzip_round_trip() {
215        let decompressor = config(DecompressionAlgorithm::Gzip, None).build().unwrap();
216        let payload = b"hello gzip";
217        assert_eq!(
218            decompressor.decompress(&gzip_compress(payload)).unwrap(),
219            payload
220        );
221    }
222
223    #[test]
224    fn zlib_round_trip() {
225        let decompressor = config(DecompressionAlgorithm::Zlib, None).build().unwrap();
226        let payload = b"hello zlib";
227        assert_eq!(
228            decompressor.decompress(&zlib_compress(payload)).unwrap(),
229            payload
230        );
231    }
232
233    #[test]
234    fn zstd_round_trip() {
235        let decompressor = config(DecompressionAlgorithm::Zstd, None).build().unwrap();
236        let payload = b"hello zstd";
237        let compressed = zstd::stream::encode_all(&payload[..], 3).unwrap();
238        assert_eq!(decompressor.decompress(&compressed).unwrap(), payload);
239    }
240
241    #[test]
242    fn zstd_dictionary_round_trip() {
243        let dictionary = train_dictionary();
244        let dictionary_file = write_temp_dictionary(&dictionary);
245        let decompressor = config(
246            DecompressionAlgorithm::Zstd,
247            Some(dictionary_file.path().to_path_buf()),
248        )
249        .build()
250        .unwrap();
251
252        let payload = br#"{"id":123,"message":"hello dictionary"}"#;
253        let compressed = zstd::bulk::Compressor::with_dictionary(3, &dictionary)
254            .unwrap()
255            .compress(payload)
256            .unwrap();
257
258        assert_eq!(decompressor.decompress(&compressed).unwrap(), payload);
259    }
260
261    #[test]
262    fn invalid_payload_is_an_error() {
263        let decompressor = config(DecompressionAlgorithm::Zstd, None).build().unwrap();
264        assert!(decompressor.decompress(b"not zstd data").is_err());
265    }
266
267    #[test]
268    fn build_rejects_dictionary_with_non_zstd_algorithm() {
269        let error = config(
270            DecompressionAlgorithm::Gzip,
271            Some(PathBuf::from("/etc/vector/compression.dict")),
272        )
273        .build()
274        .expect_err("dictionary with gzip should be rejected");
275        assert!(error.to_string().contains("only `zstd`"));
276    }
277
278    #[test]
279    fn build_rejects_missing_dictionary_file() {
280        let error = config(
281            DecompressionAlgorithm::Zstd,
282            Some(PathBuf::from("/nonexistent/compression.dict")),
283        )
284        .build()
285        .expect_err("missing dictionary file should be rejected");
286        assert!(error.to_string().contains("Failed to read"));
287    }
288}