1use anyhow::{Context, Result, bail};
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum StorageTier {
22 #[default]
24 Hot,
25 Warm,
27 Cold,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Codec {
35 #[default]
37 None,
38 Zstd,
40 ZstdBlocks,
42}
43
44impl StorageTier {
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Hot => "hot",
48 Self::Warm => "warm",
49 Self::Cold => "cold",
50 }
51 }
52}
53
54impl Codec {
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::None => "none",
58 Self::Zstd => "zstd",
59 Self::ZstdBlocks => "zstd_blocks",
60 }
61 }
62}
63
64pub const DEFAULT_WARM_BLOCK: u32 = 1 << 20;
66
67const ZBLK_MAGIC: &[u8; 4] = b"ZBLK";
68
69pub fn encode_zstd_blocks(raw: &[u8], block_size: u32) -> Result<Vec<u8>> {
71 if block_size == 0 {
72 bail!("warm block_size must be > 0");
73 }
74 let block_size = block_size as usize;
75 let mut out = Vec::new();
76 out.extend_from_slice(ZBLK_MAGIC);
77 out.extend_from_slice(&(block_size as u32).to_le_bytes());
78 out.extend_from_slice(&(raw.len() as u64).to_le_bytes());
79 let n_blocks = raw.len().div_ceil(block_size);
80 out.extend_from_slice(&(n_blocks as u32).to_le_bytes());
81 for chunk in raw.chunks(block_size) {
82 let comp = z_encode_all(chunk, 3)?;
83 out.extend_from_slice(&(comp.len() as u32).to_le_bytes());
84 out.extend_from_slice(&comp);
85 }
86 Ok(out)
87}
88
89pub fn decode_zstd_blocks(comp: &[u8]) -> Result<Vec<u8>> {
91 decode_zstd_blocks_impl(comp, false)
92}
93
94pub fn decode_zstd_blocks_parallel(comp: &[u8]) -> Result<Vec<u8>> {
96 decode_zstd_blocks_impl(comp, true)
97}
98
99#[cfg(not(target_arch = "wasm32"))]
108fn z_encode_all(data: &[u8], level: i32) -> Result<Vec<u8>> {
109 zstd::encode_all(data, level).context("zstd compress")
110}
111#[cfg(not(target_arch = "wasm32"))]
112fn z_decode_all(data: &[u8]) -> Result<Vec<u8>> {
113 zstd::decode_all(data).context("zstd decompress")
114}
115#[cfg(target_arch = "wasm32")]
116fn z_encode_all(_data: &[u8], _level: i32) -> Result<Vec<u8>> {
117 bail!("rlx-pkg: zstd compression unavailable on wasm32 (use uncompressed/hot-tier packages)")
118}
119#[cfg(target_arch = "wasm32")]
120fn z_decode_all(_data: &[u8]) -> Result<Vec<u8>> {
121 bail!("rlx-pkg: zstd decompression unavailable on wasm32 (use uncompressed/hot-tier packages)")
122}
123
124#[cfg(not(target_arch = "wasm32"))]
125fn zstd_decode_bounded(input: &[u8], expected: usize) -> Result<Vec<u8>> {
126 use std::io::Read;
127 let mut dec = zstd::Decoder::new(input).context("init zstd block decoder")?;
128 let mut out = Vec::new();
129 dec.by_ref()
130 .take(expected as u64 + 1)
131 .read_to_end(&mut out)
132 .context("zstd decode block")?;
133 if out.len() != expected {
134 bail!(
135 "warm block decoded len {} != expected {expected}",
136 out.len()
137 );
138 }
139 Ok(out)
140}
141#[cfg(target_arch = "wasm32")]
142fn zstd_decode_bounded(_input: &[u8], _expected: usize) -> Result<Vec<u8>> {
143 bail!("rlx-pkg: zstd block decode unavailable on wasm32 (use uncompressed/hot-tier packages)")
144}
145
146fn decode_zstd_blocks_impl(comp: &[u8], parallel: bool) -> Result<Vec<u8>> {
147 if comp.len() < 4 + 4 + 8 + 4 {
148 bail!("warm blob too short");
149 }
150 if &comp[..4] != ZBLK_MAGIC {
151 bail!("warm blob missing ZBLK magic");
152 }
153 let block_size = u32::from_le_bytes(comp[4..8].try_into().unwrap()) as usize;
154 let raw_len = u64::from_le_bytes(comp[8..16].try_into().unwrap()) as usize;
155 let n_blocks = u32::from_le_bytes(comp[16..20].try_into().unwrap()) as usize;
156 if raw_len == 0 {
162 if n_blocks != 0 {
163 bail!("warm blob: raw_len 0 but claims {n_blocks} blocks");
164 }
165 } else {
166 if block_size == 0 {
167 bail!("warm blob: block_size 0 with nonzero raw_len");
168 }
169 if n_blocks != raw_len.div_ceil(block_size) {
170 bail!(
171 "warm blob: n_blocks {n_blocks} inconsistent with raw_len {raw_len} / block_size {block_size}"
172 );
173 }
174 }
175 const MAX_WARM_DECOMPRESS_RATIO: usize = 1000;
179 if raw_len > comp.len().saturating_mul(MAX_WARM_DECOMPRESS_RATIO) {
180 bail!(
181 "warm blob decompression ratio too high (raw_len {raw_len} vs {} compressed bytes) — possible bomb",
182 comp.len()
183 );
184 }
185 let mut ranges = Vec::with_capacity(n_blocks.min(comp.len().saturating_sub(20) / 4));
191 let mut pos = 20usize;
192 for i in 0..n_blocks {
193 if pos + 4 > comp.len() {
194 bail!("warm blob truncated at block {i} header");
195 }
196 let clen = u32::from_le_bytes(comp[pos..pos + 4].try_into().unwrap()) as usize;
197 pos += 4;
198 if pos + clen > comp.len() {
199 bail!("warm blob truncated at block {i} data");
200 }
201 ranges.push((pos, pos + clen));
202 pos += clen;
203 }
204
205 let expected = |i: usize| -> usize {
209 if i + 1 < n_blocks {
210 block_size
211 } else {
212 raw_len - block_size * (n_blocks - 1)
213 }
214 };
215 let pieces: Result<Vec<Vec<u8>>> = if parallel && n_blocks > 1 {
216 use rayon::prelude::*;
217 ranges
218 .par_iter()
219 .enumerate()
220 .map(|(i, (a, b))| {
221 zstd_decode_bounded(&comp[*a..*b], expected(i))
222 .with_context(|| format!("zstd decompress block {i}"))
223 })
224 .collect()
225 } else {
226 ranges
227 .iter()
228 .enumerate()
229 .map(|(i, (a, b))| {
230 zstd_decode_bounded(&comp[*a..*b], expected(i))
231 .with_context(|| format!("zstd decompress block {i}"))
232 })
233 .collect()
234 };
235 let pieces = pieces?;
236 let out_len: usize = pieces.iter().map(|p| p.len()).sum();
240 let mut out = Vec::with_capacity(out_len);
241 for p in pieces {
242 out.extend_from_slice(&p);
243 }
244 if out.len() != raw_len {
245 bail!("warm decode length {} != declared {raw_len}", out.len());
246 }
247 Ok(out)
248}
249
250pub fn decode_zstd_block_at(comp: &[u8], block_index: usize) -> Result<Vec<u8>> {
252 if comp.len() < 20 || &comp[..4] != ZBLK_MAGIC {
253 bail!("not a ZBLK warm blob");
254 }
255 let n_blocks = u32::from_le_bytes(comp[16..20].try_into().unwrap()) as usize;
256 if block_index >= n_blocks {
257 bail!("block_index {block_index} out of range ({n_blocks})");
258 }
259 let mut pos = 20usize;
260 for i in 0..=block_index {
261 if pos + 4 > comp.len() {
262 bail!("warm blob truncated");
263 }
264 let clen = u32::from_le_bytes(comp[pos..pos + 4].try_into().unwrap()) as usize;
265 pos += 4;
266 if pos + clen > comp.len() {
267 bail!("warm blob truncated");
268 }
269 if i == block_index {
270 return z_decode_all(&comp[pos..pos + clen]);
271 }
272 pos += clen;
273 }
274 unreachable!()
275}
276
277pub fn encode_zstd(raw: &[u8]) -> Result<Vec<u8>> {
278 z_encode_all(raw, 3)
279}
280
281pub fn decode_zstd(comp: &[u8]) -> Result<Vec<u8>> {
282 z_decode_all(comp)
283}
284
285pub fn decode_payload(codec: Codec, stored: &[u8]) -> Result<Vec<u8>> {
287 match codec {
288 Codec::None => Ok(stored.to_vec()),
289 Codec::Zstd => decode_zstd(stored),
290 Codec::ZstdBlocks => decode_zstd_blocks(stored),
291 }
292}
293
294pub fn decode_payload_parallel(codec: Codec, stored: &[u8]) -> Result<Vec<u8>> {
296 match codec {
297 Codec::None => Ok(stored.to_vec()),
298 Codec::Zstd => decode_zstd(stored),
299 Codec::ZstdBlocks => decode_zstd_blocks_parallel(stored),
300 }
301}
302
303pub fn encode_for_tier(tier: StorageTier, raw: &[u8], warm_block: u32) -> Result<(Codec, Vec<u8>)> {
305 match tier {
306 StorageTier::Hot => Ok((Codec::None, raw.to_vec())),
307 StorageTier::Warm => Ok((Codec::ZstdBlocks, encode_zstd_blocks(raw, warm_block)?)),
308 StorageTier::Cold => Ok((Codec::Zstd, encode_zstd(raw)?)),
309 }
310}
311
312pub fn checksum_hex(raw: &[u8]) -> String {
314 format!("{:016x}", xxhash_rust::xxh3::xxh3_64(raw))
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
325 fn warm_roundtrip_multiblock() {
326 let raw: Vec<u8> = (0..(256 * 2 + 100)).map(|i| (i % 251) as u8).collect();
327 let enc = encode_zstd_blocks(&raw, 256).unwrap();
328 assert_eq!(decode_zstd_blocks_parallel(&enc).unwrap(), raw);
329 assert_eq!(decode_zstd_blocks(&enc).unwrap(), raw);
330 }
331
332 #[test]
335 fn warm_rejects_bomb_and_tampered_headers() {
336 let raw: Vec<u8> = (0..1000u32).map(|i| (i % 7) as u8).collect();
337 let enc = encode_zstd_blocks(&raw, 256).unwrap();
338
339 let mut bomb = enc.clone();
341 bomb[8..16].copy_from_slice(&u64::MAX.to_le_bytes());
342 assert!(decode_zstd_blocks(&bomb).is_err());
343
344 let mut bad_nb = enc.clone();
346 bad_nb[16..20].copy_from_slice(&9999u32.to_le_bytes());
347 assert!(decode_zstd_blocks(&bad_nb).is_err());
348 }
349}