spacedb_store/compress.rs
1//! Transparent per-row compression **inside** the AEAD boundary.
2//!
3//! The law is *compress, then encrypt*: ciphertext is incompressible, so the
4//! only place compression can work is on the plaintext, before [`crate::crypto::seal_row`].
5//! A prefixed collection therefore seals `format_byte ‖ payload`:
6//!
7//! - [`FORMAT_RAW`] (`0x00`) — payload is the encoded value, verbatim;
8//! - [`FORMAT_ZSTD`] (`0x01`) — payload is an RFC 8878 zstd frame of it;
9//! - `0x02` is **reserved** for dictionary frames (a dictionary is part of the
10//! on-disk format and must be stored, versioned and re-wrapped like a key —
11//! deliberately not shipped in the first cut).
12//!
13//! The format byte rides inside the sealed plaintext, so it is authenticated by
14//! the AEAD and invisible to whoever holds the ciphertext. Compression is
15//! attempted only above a floor ([`Compression::min_len`]) and kept only when
16//! it actually shrank the payload — incompressible rows store raw at a cost of
17//! one byte.
18//!
19//! **The length side-channel rule (normative, from the deploy plan W1.2):**
20//! compression leaks plaintext redundancy through ciphertext *length*. Never
21//! mix user-secret and attacker-influenced data in one compressed row; a
22//! collection whose rows do so must opt out with [`Compression::Off`].
23
24use crate::error::{StoreError, StoreResult};
25
26/// Format byte: the payload is the encoded value, verbatim.
27pub const FORMAT_RAW: u8 = 0x00;
28/// Format byte: the payload is a zstd frame of the encoded value.
29pub const FORMAT_ZSTD: u8 = 0x01;
30
31/// The default zstd level. Level 3 is the ratio/CPU knee for small structured
32/// rows; raise it only with a measurement.
33pub const DEFAULT_COMPRESSION_LEVEL: i32 = 3;
34
35/// The default floor below which compression is not attempted. Set from the
36/// floor sweep in `examples/seal_ab.rs` (2026-08-28, structured-row corpus,
37/// floor disabled): at ≤64 B compression never won (every row fell back raw);
38/// at 96 B marginal wins appeared (−0.0%); at 128 B real wins began (−5.6%),
39/// growing monotonically (−23.5% at 256 B, −59.4% at 512 B). The floor's only
40/// cost is a wasted compress attempt — the strictly-smaller check already
41/// protects size — so it sits where wins *start existing*, not where they get
42/// big. Re-derive with the sweep, don't hand-tune.
43pub const DEFAULT_MIN_LEN: usize = 96;
44
45/// Per-collection write-side compression policy. Reads always honor the
46/// per-row format byte regardless of policy, so flipping the policy never
47/// strands existing rows.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum Compression {
50 /// Compress rows of at least `min_len` encoded bytes at `level`, keeping
51 /// the result only when it is strictly smaller than storing raw.
52 On {
53 /// zstd level (see [`DEFAULT_COMPRESSION_LEVEL`]).
54 level: i32,
55 /// Encoded-value floor below which compression is not attempted.
56 min_len: usize,
57 },
58 /// Never compress. Required for collections whose rows mix user-secret and
59 /// attacker-influenced bytes (the length side-channel rule above).
60 Off,
61}
62
63impl Default for Compression {
64 fn default() -> Self {
65 Compression::On {
66 level: DEFAULT_COMPRESSION_LEVEL,
67 min_len: DEFAULT_MIN_LEN,
68 }
69 }
70}
71
72/// Wrap an encoded value in the prefixed format, compressing when the policy
73/// says to and it pays. Infallible by design: a compression failure falls back
74/// to raw — a put must never fail because a compressor declined.
75pub(crate) fn pack_value(plain: &[u8], policy: Compression) -> Vec<u8> {
76 if let Compression::On { level, min_len } = policy {
77 if plain.len() >= min_len {
78 if let Ok(z) = rusty_zstd::compress(plain, level) {
79 if z.len() < plain.len() {
80 let mut out = Vec::with_capacity(1 + z.len());
81 out.push(FORMAT_ZSTD);
82 out.extend_from_slice(&z);
83 return out;
84 }
85 }
86 }
87 }
88 let mut out = Vec::with_capacity(1 + plain.len());
89 out.push(FORMAT_RAW);
90 out.extend_from_slice(plain);
91 out
92}
93
94/// Unwrap a prefixed payload back to the encoded value. The input is
95/// AEAD-authenticated (it came out of `open_row`), so a bad format byte or a
96/// broken frame is corruption or a version mix-up, not attacker data — it
97/// fails loudly rather than decoding garbage.
98pub(crate) fn unpack_value(packed: &[u8]) -> StoreResult<Vec<u8>> {
99 match packed.split_first() {
100 Some((&FORMAT_RAW, rest)) => Ok(rest.to_vec()),
101 Some((&FORMAT_ZSTD, rest)) => rusty_zstd::decompress(rest)
102 .map_err(|e| StoreError::Compression(format!("zstd frame: {e:?}"))),
103 Some((&byte, _)) => Err(StoreError::Compression(format!(
104 "unknown row format byte {byte:#04x}"
105 ))),
106 None => Err(StoreError::Compression("empty sealed payload".into())),
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn raw_round_trips() {
116 let plain = b"hello world";
117 let packed = pack_value(plain, Compression::Off);
118 assert_eq!(packed[0], FORMAT_RAW);
119 assert_eq!(unpack_value(&packed).unwrap(), plain);
120 }
121
122 #[test]
123 fn compressible_payload_shrinks_and_round_trips() {
124 let plain = vec![b'a'; 4096];
125 let packed = pack_value(&plain, Compression::default());
126 assert_eq!(packed[0], FORMAT_ZSTD);
127 assert!(packed.len() < plain.len());
128 assert_eq!(unpack_value(&packed).unwrap(), plain);
129 }
130
131 #[test]
132 fn incompressible_payload_stays_raw() {
133 // Deterministic pseudo-random bytes do not compress; the policy must
134 // fall back to raw rather than storing an expanded frame.
135 let mut state = 0x9E3779B97F4A7C15u64;
136 let plain: Vec<u8> = (0..4096)
137 .map(|_| {
138 state ^= state << 13;
139 state ^= state >> 7;
140 state ^= state << 17;
141 (state >> 56) as u8
142 })
143 .collect();
144 let packed = pack_value(&plain, Compression::default());
145 assert_eq!(packed[0], FORMAT_RAW);
146 assert_eq!(packed.len(), plain.len() + 1);
147 assert_eq!(unpack_value(&packed).unwrap(), plain);
148 }
149
150 #[test]
151 fn below_the_floor_is_not_attempted() {
152 let plain = vec![b'a'; 8];
153 let packed = pack_value(
154 &plain,
155 Compression::On {
156 level: DEFAULT_COMPRESSION_LEVEL,
157 min_len: 64,
158 },
159 );
160 assert_eq!(packed[0], FORMAT_RAW);
161 }
162
163 #[test]
164 fn unknown_format_byte_fails_loudly() {
165 assert!(unpack_value(&[0x7F, 1, 2, 3]).is_err());
166 assert!(unpack_value(&[]).is_err());
167 }
168}