Skip to main content

objstore_r/
encoding.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Per-object content encoding (`Content-Encoding`), opt in per write.
5//!
6//! [`ObjectStore::put_encoded`](crate::ObjectStore::put_encoded) compresses a
7//! body before it is stored and records the encoding beside it (GCS: the
8//! object's `contentEncoding`; [`LocalDir`](crate::LocalDir) and
9//! [`Mem`](crate::Mem): the backend's own metadata). Reads decode it:
10//!
11//! - **Decoded once.** [`get`](crate::ObjectStore::get) inflates into one
12//!   fresh buffer and returns it as [`Bytes`]; from there the body is shared
13//!   by reference count like any other, so zero-copy consumers (an mmap'd
14//!   file written from it, a Python buffer view) are unaffected.
15//! - **No ranges.** A byte range of a gzip stream is meaningless, and handing
16//!   back compressed bytes would be silent corruption, so
17//!   [`get_range`](crate::ObjectStore::get_range) of an encoded object is
18//!   [`Error::EncodedRange`]. Encode only objects that are read whole.
19//! - **Bounded.** A small object can inflate without bound (a decompression
20//!   bomb). Decoding stops at the store's inflated-size limit —
21//!   [`MAX_INFLATED_BYTES`] unless the store was built `with_max_inflated` —
22//!   with [`Error::TooLarge`]; a truncated or damaged stream is
23//!   [`Error::Corrupt`]. Neither ever panics.
24//!
25//! The codec is feature `gzip` (flate2 on the pure-Rust `miniz_oxide` backend).
26//! Without it the type still exists, and every encoded write or read is
27//! [`Error::UnsupportedEncoding`] rather than raw compressed bytes.
28
29use crate::{Bytes, Error, Result};
30
31/// Default bound on a decoded body: 1 GiB. A gzip stream inflates at most
32/// ~1032:1, so the bound, not the stored size, is what caps the memory one
33/// read can claim. Raise or lower it per store with `with_max_inflated`.
34pub const MAX_INFLATED_BYTES: u64 = 1 << 30;
35
36/// How an object's stored bytes are encoded. No encoding (`identity`) is
37/// `Option::<ContentEncoding>::None` throughout the API.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
39#[non_exhaustive]
40pub enum ContentEncoding {
41    /// RFC 1952 gzip (`Content-Encoding: gzip`).
42    Gzip,
43}
44
45impl ContentEncoding {
46    /// The `Content-Encoding` token.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            ContentEncoding::Gzip => "gzip",
51        }
52    }
53
54    /// Parse a `Content-Encoding` token (ASCII case-insensitive): `gzip`, or
55    /// `None` for an empty value or `identity`. Anything else is
56    /// [`Error::UnsupportedEncoding`].
57    pub fn parse(token: &str) -> Result<Option<Self>> {
58        let t = token.trim();
59        if t.is_empty() || t.eq_ignore_ascii_case("identity") {
60            Ok(None)
61        } else if t.eq_ignore_ascii_case("gzip") {
62            Ok(Some(ContentEncoding::Gzip))
63        } else {
64            Err(unsupported(t))
65        }
66    }
67}
68
69impl std::fmt::Display for ContentEncoding {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(self.as_str())
72    }
73}
74
75fn unsupported(token: &str) -> Error {
76    // Bounded: the token may come from a server or a file.
77    Error::UnsupportedEncoding { encoding: token.chars().take(64).collect() }
78}
79
80/// Compress `body` for storage.
81#[cfg_attr(not(feature = "gzip"), allow(clippy::unnecessary_wraps))]
82pub(crate) fn encode(encoding: ContentEncoding, body: &[u8]) -> Result<Bytes> {
83    #[cfg(feature = "gzip")]
84    {
85        use std::io::Write;
86        let ContentEncoding::Gzip = encoding;
87        // Header mtime 0 and a fixed OS byte (flate2's defaults): the same
88        // body always encodes to the same bytes, so a retried create-only
89        // write of an immutable object stores identical bytes.
90        let mut enc = flate2::write::GzEncoder::new(
91            Vec::with_capacity(body.len() / 4 + 64),
92            flate2::Compression::default(),
93        );
94        enc.write_all(body)?;
95        Ok(Bytes::from(enc.finish()?))
96    }
97    #[cfg(not(feature = "gzip"))]
98    {
99        let _ = body;
100        Err(unsupported(encoding.as_str()))
101    }
102}
103
104/// Decode stored bytes into one fresh buffer, refusing to inflate past
105/// `limit` bytes ([`Error::TooLarge`]) and any malformed, truncated or
106/// trailing-garbage stream ([`Error::Corrupt`]).
107pub(crate) fn decode(encoding: ContentEncoding, stored: &[u8], limit: u64) -> Result<Bytes> {
108    #[cfg(feature = "gzip")]
109    {
110        use std::io::Read;
111        let ContentEncoding::Gzip = encoding;
112        // Capacity from the trailer's ISIZE (the size mod 2^32), trusted only
113        // as far as the stored length could possibly inflate and the limit
114        // allows: a lying trailer cannot make this allocate.
115        let isize_hint = stored
116            .len()
117            .checked_sub(4)
118            .and_then(|at| stored.get(at..))
119            .map_or(0, |t| u64::from(u32::from_le_bytes([t[0], t[1], t[2], t[3]])));
120        let ceiling = (stored.len() as u64).saturating_mul(1032);
121        let hint = isize_hint.min(ceiling).min(limit);
122        let mut out = Vec::with_capacity(usize::try_from(hint).unwrap_or(0));
123        // Concatenated members are valid gzip (RFC 1952 §2.2): decode all.
124        // One byte past the limit proves the stream is larger than allowed
125        // without inflating any further.
126        let mut dec = flate2::read::MultiGzDecoder::new(stored).take(limit.saturating_add(1));
127        dec.read_to_end(&mut out).map_err(|e| Error::corrupt(format!("gzip stream: {e}")))?;
128        if out.len() as u64 > limit {
129            return Err(Error::TooLarge { limit });
130        }
131        Ok(Bytes::from(out))
132    }
133    #[cfg(not(feature = "gzip"))]
134    {
135        let _ = (stored, limit);
136        Err(unsupported(encoding.as_str()))
137    }
138}
139
140/// Fuzzing entry point: decode anything under a small bound; a typed error or
141/// a body within the bound, never a panic.
142#[doc(hidden)]
143pub fn fuzz(stored: &[u8]) {
144    const LIMIT: u64 = 1 << 20;
145    if let Ok(b) = decode(ContentEncoding::Gzip, stored, LIMIT) {
146        assert!(b.len() as u64 <= LIMIT);
147        if let Ok(again) = encode(ContentEncoding::Gzip, &b) {
148            assert_eq!(decode(ContentEncoding::Gzip, &again, LIMIT).ok().as_ref(), Some(&b));
149        }
150    }
151    let _ = ContentEncoding::parse(&String::from_utf8_lossy(stored));
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn tokens_parse_and_print() {
160        assert_eq!(ContentEncoding::parse("gzip").unwrap(), Some(ContentEncoding::Gzip));
161        assert_eq!(ContentEncoding::parse(" GZip ").unwrap(), Some(ContentEncoding::Gzip));
162        assert_eq!(ContentEncoding::parse("identity").unwrap(), None);
163        assert_eq!(ContentEncoding::parse("").unwrap(), None);
164        for bad in ["br", "deflate", "gzip, br", "x-gzip"] {
165            assert!(
166                matches!(ContentEncoding::parse(bad), Err(Error::UnsupportedEncoding { .. })),
167                "{bad}"
168            );
169        }
170        assert_eq!(ContentEncoding::Gzip.to_string(), "gzip");
171        let long = "z".repeat(10_000);
172        let Err(Error::UnsupportedEncoding { encoding }) = ContentEncoding::parse(&long) else {
173            panic!("unknown token accepted")
174        };
175        assert_eq!(encoding.len(), 64, "a hostile token is bounded in the error");
176    }
177
178    #[cfg(feature = "gzip")]
179    #[test]
180    fn round_trip_bounds_and_damage() {
181        let body: Vec<u8> = b"the same line, again and again\n".repeat(1000);
182        let gz = encode(ContentEncoding::Gzip, &body).unwrap();
183        assert!(gz.len() * 10 < body.len(), "{} -> {}", body.len(), gz.len());
184        assert_eq!(gz, encode(ContentEncoding::Gzip, &body).unwrap(), "deterministic");
185        assert_eq!(decode(ContentEncoding::Gzip, &gz, body.len() as u64).unwrap(), body);
186        assert!(matches!(
187            decode(ContentEncoding::Gzip, &gz, body.len() as u64 - 1),
188            Err(Error::TooLarge { .. })
189        ));
190        assert!(matches!(
191            decode(ContentEncoding::Gzip, &gz[..gz.len() - 3], u64::MAX),
192            Err(Error::Corrupt { .. })
193        ));
194        let mut bad = gz.to_vec();
195        let mid = bad.len() / 2;
196        bad[mid] ^= 0x55;
197        assert!(decode(ContentEncoding::Gzip, &bad, u64::MAX).is_err());
198        assert!(matches!(decode(ContentEncoding::Gzip, b"", 10), Err(Error::Corrupt { .. })));
199        assert!(matches!(
200            decode(ContentEncoding::Gzip, b"plain text", 10),
201            Err(Error::Corrupt { .. })
202        ));
203        // Two members concatenated decode to both bodies.
204        let mut two = encode(ContentEncoding::Gzip, b"ab").unwrap().to_vec();
205        two.extend_from_slice(&encode(ContentEncoding::Gzip, b"cd").unwrap());
206        assert_eq!(decode(ContentEncoding::Gzip, &two, 10).unwrap(), &b"abcd"[..]);
207        // The empty body is a valid stream.
208        let empty = encode(ContentEncoding::Gzip, b"").unwrap();
209        assert!(decode(ContentEncoding::Gzip, &empty, 0).unwrap().is_empty());
210    }
211
212    #[cfg(not(feature = "gzip"))]
213    #[test]
214    fn without_the_codec_encoded_io_is_refused() {
215        assert!(matches!(
216            encode(ContentEncoding::Gzip, b"x"),
217            Err(Error::UnsupportedEncoding { .. })
218        ));
219        assert!(matches!(
220            decode(ContentEncoding::Gzip, b"x", 1),
221            Err(Error::UnsupportedEncoding { .. })
222        ));
223    }
224}