/build/source/nativelink-util/src/wire_compression.rs
Line | Count | Source |
1 | | // Copyright 2026 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | //! Zstd wire-compression codecs for REAPI compressed-blobs. |
16 | | //! |
17 | | //! Shared by the server-side accept/serve paths (nativelink-service) and the |
18 | | //! client-side `GrpcStore` transfers (nativelink-store). This is orthogonal |
19 | | //! to at-rest compression (`CompressionStore` with LZ4). |
20 | | |
21 | | use std::io::Read; |
22 | | |
23 | | use bytes::Bytes; |
24 | | use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err}; |
25 | | use nativelink_proto::build::bazel::remote::execution::v2::compressor; |
26 | | |
27 | | use crate::buf_channel::{DropCloserReadHalf, DropCloserWriteHalf}; |
28 | | use crate::common::DigestInfo; |
29 | | use crate::digest_hasher::{DigestHasher, DigestHasherFunc}; |
30 | | use crate::store_trait::UploadSizeInfo; |
31 | | |
32 | | /// Zstd compression level for wire compression. |
33 | | /// Level 0 in the zstd crate means "use default" (currently 3). |
34 | | /// We use an explicit level for clarity. |
35 | | pub const ZSTD_COMPRESSION_LEVEL: i32 = 3; |
36 | | |
37 | | /// Upper bound on the buffer `decompress` reserves up front for a zstd blob. |
38 | | /// `expected_size` comes from the client's claimed digest, so it must never be |
39 | | /// used as an allocation hint directly: a small payload claiming a huge size |
40 | | /// would otherwise force a large pre-emptive allocation before the real |
41 | | /// decompressed length is ever known. We reserve `min(expected_size, this)` |
42 | | /// so common payloads never reallocate while a hostile claim allocates at most |
43 | | /// this. Sized comfortably above any honest `BatchUpdateBlobs` payload (the |
44 | | /// only caller of this bulk path; large blobs stream through `ByteStream`). |
45 | | const ZSTD_DECOMPRESS_PREALLOC_CAP: usize = 1024 * 1024; |
46 | | |
47 | | /// Compress data using the specified wire compressor. |
48 | | /// |
49 | | /// `data` is the raw (uncompressed) bytes from the store. |
50 | | /// Returns the compressed bytes suitable for sending on the wire. |
51 | 12 | pub fn compress(data: Bytes, compressor_value: compressor::Value) -> Result<Bytes, Error> { |
52 | 12 | match compressor_value { |
53 | 1 | compressor::Value::Identity => Ok(data), |
54 | | compressor::Value::Zstd => { |
55 | 11 | let compressed = zstd::bulk::compress(&data, ZSTD_COMPRESSION_LEVEL) |
56 | 11 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
57 | 11 | Ok(Bytes::from(compressed)) |
58 | | } |
59 | 0 | _ => Err(make_input_err!( |
60 | 0 | "Unsupported wire compressor for compression: {:?}", |
61 | 0 | compressor_value |
62 | 0 | )), |
63 | | } |
64 | 12 | } |
65 | | |
66 | | /// Decompress data using the specified wire compressor. |
67 | | /// |
68 | | /// `data` is the compressed bytes received from the wire. |
69 | | /// `expected_size` is the uncompressed size (from the client's digest). It is |
70 | | /// the hard cap on the decompressed output, but never a direct allocation |
71 | | /// hint: the buffer grows with the real decoded bytes so a small payload |
72 | | /// claiming a huge size cannot force a large up-front allocation. |
73 | | /// Returns the decompressed bytes suitable for storing. |
74 | 7 | pub fn decompress( |
75 | 7 | data: &[u8], |
76 | 7 | compressor_value: compressor::Value, |
77 | 7 | expected_size: usize, |
78 | 7 | ) -> Result<Bytes, Error> { |
79 | 7 | match compressor_value { |
80 | | compressor::Value::Identity => { |
81 | 2 | if data.len() != expected_size { |
82 | 1 | return Err(make_err!( |
83 | 1 | Code::InvalidArgument, |
84 | 1 | "Identity data size {} does not match expected size {}", |
85 | 1 | data.len(), |
86 | 1 | expected_size |
87 | 1 | )); |
88 | 1 | } |
89 | 1 | Ok(Bytes::copy_from_slice(data)) |
90 | | } |
91 | | compressor::Value::Zstd => { |
92 | | // Decode incrementally so `expected_size` (which is attacker |
93 | | // controlled — it is the client's claimed digest size) can bound |
94 | | // the output without being trusted as an allocation size. We |
95 | | // reserve only `min(expected_size, ZSTD_DECOMPRESS_PREALLOC_CAP)`, |
96 | | // then `take(expected_size + 1)` hard-caps the decoder so a |
97 | | // decompression bomb is rejected as soon as it overshoots. This |
98 | | // mirrors the real-byte-count validation the identity arm and the |
99 | | // streaming upload path already perform. |
100 | 5 | let decoder = zstd::stream::read::Decoder::new(data) |
101 | 5 | .map_err(|e| make_err!0 (Code::InvalidArgument0 , "Zstd decompression failed: {e}"))?0 ; |
102 | 5 | let mut output = Vec::with_capacity(expected_size.min(ZSTD_DECOMPRESS_PREALLOC_CAP)); |
103 | | // `+ 1` lets an oversized stream produce one byte past the cap so |
104 | | // the size check below rejects it rather than silently truncating. |
105 | 5 | let cap = u64::try_from(expected_size) |
106 | 5 | .err_tip(|| "expected_size did not fit in u64")?0 |
107 | 5 | .saturating_add(1); |
108 | 5 | decoder |
109 | 5 | .take(cap) |
110 | 5 | .read_to_end(&mut output) |
111 | 5 | .map_err(|e| make_err!0 (Code::InvalidArgument0 , "Zstd decompression failed: {e}"))?0 ; |
112 | 5 | if output.len() != expected_size { |
113 | 3 | return Err(make_err!( |
114 | 3 | Code::InvalidArgument, |
115 | 3 | "Decompressed size {} does not match expected size {}", |
116 | 3 | output.len(), |
117 | 3 | expected_size |
118 | 3 | )); |
119 | 2 | } |
120 | 2 | Ok(Bytes::from(output)) |
121 | | } |
122 | 0 | _ => Err(make_input_err!( |
123 | 0 | "Unsupported wire compressor for decompression: {:?}", |
124 | 0 | compressor_value |
125 | 0 | )), |
126 | | } |
127 | 7 | } |
128 | | |
129 | | /// Decode a client's zstd wire stream into raw bytes on `tx`, asynchronously. |
130 | | /// |
131 | | /// Like [`stream_encode_compressed_download`], this must not occupy a tokio |
132 | | /// blocking-pool thread for the stream's lifetime: the input arrives at the |
133 | | /// client's upload pace and `tx` drains at the store's write pace, so a |
134 | | /// blocking implementation parks a pool thread on whichever side is slower |
135 | | /// for as long as the upload lasts. The zstd frame is consumed incrementally |
136 | | /// with the raw streaming API instead; per-chunk decode cost is bounded by |
137 | | /// the channel chunk size, so it runs inline on the async runtime with |
138 | | /// channel-native backpressure on both sides. |
139 | | /// |
140 | | /// Validation semantics match the REAPI compressed-blobs contract: the |
141 | | /// decoded byte count may never exceed the digest size (checked per chunk so |
142 | | /// a decompression bomb is rejected as soon as it overshoots), the final |
143 | | /// count must equal it exactly, and the decoded bytes must hash to `digest`. |
144 | 13 | pub async fn stream_decode_compressed_upload( |
145 | 13 | mut compressed_rx: DropCloserReadHalf, |
146 | 13 | wire_compressor: compressor::Value, |
147 | 13 | digest: DigestInfo, |
148 | 13 | digest_function: DigestHasherFunc, |
149 | 13 | mut tx: DropCloserWriteHalf, |
150 | 13 | ) -> Result<(), Error> { |
151 | | use zstd::stream::raw::{Decoder, InBuffer, Operation, OutBuffer}; |
152 | | |
153 | 13 | if wire_compressor != compressor::Value::Zstd { |
154 | 0 | return Err(make_input_err!( |
155 | 0 | "Streaming upload decompression only supports zstd, got {:?}", |
156 | 0 | wire_compressor |
157 | 0 | )); |
158 | 13 | } |
159 | | |
160 | 13 | let expected_size = digest.size_bytes(); |
161 | 13 | let mut hasher = digest_function.hasher(); |
162 | 13 | let mut decoded_size = 0u64; |
163 | 13 | let mut decoder = Decoder::new() |
164 | 13 | .map_err(|e| make_err!0 (Code::InvalidArgument0 , "Zstd decompression failed: {}", e))?0 ; |
165 | | // `DCtx::out_size()` guarantees a full decompressed block always fits, so |
166 | | // the decoder never stalls for lack of output space within one `run`. |
167 | 13 | let mut out_buf = vec![0u8; zstd::zstd_safe::DCtx::out_size()]; |
168 | | // Last input-size hint from the decoder: nonzero at input EOF means the |
169 | | // stream ended in the middle of a frame and must be rejected. |
170 | 13 | let mut frame_input_hint = 0usize; |
171 | | loop { |
172 | 35 | let chunk33 = compressed_rx |
173 | 35 | .recv() |
174 | 35 | .await |
175 | 34 | .err_tip(|| "Failed to receive compressed data in stream_decode_compressed_upload")?1 ; |
176 | 33 | if chunk.is_empty() { |
177 | 9 | break; // EOF. |
178 | 24 | } |
179 | 24 | let mut in_buffer = InBuffer::around(&chunk); |
180 | | loop { |
181 | 115 | let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice()); |
182 | 115 | frame_input_hint = decoder.run(&mut in_buffer, &mut out_buffer).map_err(|e| {0 |
183 | 0 | make_err!(Code::InvalidArgument, "Zstd decompression failed: {}", e) |
184 | 0 | })?; |
185 | 115 | let produced = Bytes::copy_from_slice(out_buffer.as_slice()); |
186 | | // A completely full output buffer means the decoder may still |
187 | | // have buffered output to flush, even with no input left. |
188 | 115 | let output_was_full = produced.len() == out_buf.len(); |
189 | 115 | if !produced.is_empty() { |
190 | 102 | let produced_u64 = u64::try_from(produced.len()) |
191 | 102 | .err_tip(|| "Decoded chunk size was not convertible to u64")?0 ; |
192 | 102 | decoded_size = decoded_size.checked_add(produced_u64).ok_or_else(|| {0 |
193 | 0 | make_err!( |
194 | 0 | Code::InvalidArgument, |
195 | | "Decoded compressed upload size overflow" |
196 | | ) |
197 | 0 | })?; |
198 | 102 | if decoded_size > expected_size { |
199 | 1 | return Err(make_err!( |
200 | 1 | Code::InvalidArgument, |
201 | 1 | "Decoded compressed upload size {} bytes exceeds digest size {} bytes", |
202 | 1 | decoded_size, |
203 | 1 | expected_size |
204 | 1 | )); |
205 | 101 | } |
206 | 101 | hasher.update(&produced); |
207 | 101 | tx.send(produced).await?0 ; |
208 | 13 | } |
209 | | // `hint == 0` means the frame is completely decoded AND fully |
210 | | // flushed. It must terminate the loop even when the output |
211 | | // buffer was filled exactly: polling the decoder again after |
212 | | // frame end would return the input-size hint for a NEW frame |
213 | | // header, and the post-EOF `frame_input_hint != 0` check would |
214 | | // then misreport a fully-decoded stream as truncated. This is |
215 | | // deterministic for blobs whose decompressed size is an exact |
216 | | // multiple of the decoder output buffer size. |
217 | 113 | if in_buffer.pos() == in_buffer.src.len() && (frame_input_hint == 028 || !output_was_full19 ) |
218 | | { |
219 | 22 | break; |
220 | 91 | } |
221 | | } |
222 | | } |
223 | 9 | if frame_input_hint != 0 { |
224 | 0 | return Err(make_err!( |
225 | 0 | Code::InvalidArgument, |
226 | 0 | "Compressed upload stream ended in the middle of a zstd frame" |
227 | 0 | )); |
228 | 9 | } |
229 | | |
230 | 9 | if decoded_size != expected_size { |
231 | 0 | return Err(make_err!( |
232 | 0 | Code::InvalidArgument, |
233 | 0 | "Decompressed size {} does not match expected size {}", |
234 | 0 | decoded_size, |
235 | 0 | expected_size |
236 | 0 | )); |
237 | 9 | } |
238 | 9 | let actual_digest = hasher.finalize_digest(); |
239 | 9 | if actual_digest != digest { |
240 | 1 | return Err(make_err!( |
241 | 1 | Code::InvalidArgument, |
242 | 1 | "Decompressed digest {} does not match expected digest {}", |
243 | 1 | actual_digest, |
244 | 1 | digest |
245 | 1 | )); |
246 | 8 | } |
247 | | |
248 | 8 | tx.send_eof() |
249 | 8 | .err_tip(|| "Failed to send decompressed upload EOF")?0 ; |
250 | 8 | Ok(()) |
251 | 11 | } |
252 | | |
253 | | /// Encode a raw byte stream into a single zstd frame on `tx`, asynchronously. |
254 | | /// |
255 | | /// This runs entirely on the async runtime and must never occupy a tokio |
256 | | /// blocking-pool thread for the stream's lifetime: `tx` drains at the gRPC |
257 | | /// client's pace, so a blocking implementation (blocking reads from `raw_rx` |
258 | | /// plus `blocking_send` into `tx`) parks one pool thread per concurrent |
259 | | /// compressed download until the client finishes. Enough concurrent downloads |
260 | | /// with slow consumers then exhaust the blocking pool and starve every other |
261 | | /// `spawn_blocking` user (filesystem store I/O, upload decode, credential |
262 | | /// resolution). The zstd frame is instead produced incrementally with the raw |
263 | | /// streaming API: the CPU cost per iteration is bounded by the channel chunk |
264 | | /// size (small — micro/milliseconds), so it is acceptable inline on a worker |
265 | | /// thread, and `tx.send(...).await` gives backpressure without a parked |
266 | | /// thread. |
267 | | /// |
268 | | /// The output is one well-formed zstd frame, identical in wire format to what |
269 | | /// `zstd::stream::read::Encoder` produces (both drive `ZSTD_compressStream` |
270 | | /// on a fresh `CCtx`). |
271 | 0 | pub async fn stream_encode_compressed_download( |
272 | 0 | mut raw_rx: DropCloserReadHalf, |
273 | 0 | wire_compressor: compressor::Value, |
274 | 0 | compression_level: i32, |
275 | 0 | tx: DropCloserWriteHalf, |
276 | 5 | ) -> Result<(), Error> { |
277 | 5 | stream_encode_compressed_download_from_reader( |
278 | 5 | &mut raw_rx, |
279 | 5 | wire_compressor, |
280 | 5 | compression_level, |
281 | 5 | tx, |
282 | 5 | ) |
283 | 5 | .await |
284 | 5 | } |
285 | | |
286 | | /// Encode a raw byte stream into a single zstd frame, borrowing the input |
287 | | /// reader so a caller can continue draining it if the downstream consumer |
288 | | /// finishes before the encoder does. |
289 | 5 | pub async fn stream_encode_compressed_download_from_reader( |
290 | 5 | raw_rx: &mut DropCloserReadHalf, |
291 | 5 | wire_compressor: compressor::Value, |
292 | 5 | compression_level: i32, |
293 | 5 | tx: DropCloserWriteHalf, |
294 | 5 | ) -> Result<(), Error> { |
295 | 5 | stream_encode_from_reader(raw_rx, wire_compressor, compression_level, tx, None).await |
296 | 5 | } |
297 | | |
298 | | /// Encode an upload without finishing the compressed frame or committing EOF |
299 | | /// unless the uncompressed input satisfies its declared transfer size. |
300 | 0 | pub async fn stream_encode_compressed_upload_from_reader( |
301 | 0 | raw_rx: &mut DropCloserReadHalf, |
302 | 0 | wire_compressor: compressor::Value, |
303 | 0 | compression_level: i32, |
304 | 0 | tx: DropCloserWriteHalf, |
305 | 0 | size_info: UploadSizeInfo, |
306 | 6 | ) -> Result<(), Error> { |
307 | 6 | stream_encode_from_reader( |
308 | 6 | raw_rx, |
309 | 6 | wire_compressor, |
310 | 6 | compression_level, |
311 | 6 | tx, |
312 | 6 | Some(size_info), |
313 | 6 | ) |
314 | 6 | .await |
315 | 5 | } |
316 | | |
317 | 11 | async fn stream_encode_from_reader( |
318 | 11 | raw_rx: &mut DropCloserReadHalf, |
319 | 11 | wire_compressor: compressor::Value, |
320 | 11 | compression_level: i32, |
321 | 11 | mut tx: DropCloserWriteHalf, |
322 | 11 | size_info: Option<UploadSizeInfo>, |
323 | 11 | ) -> Result<(), Error> { |
324 | | use zstd::stream::raw::{Encoder, InBuffer, Operation, OutBuffer}; |
325 | | |
326 | 11 | if wire_compressor != compressor::Value::Zstd { |
327 | 0 | return Err(make_input_err!( |
328 | 0 | "Streaming download compression only supports zstd, got {:?}", |
329 | 0 | wire_compressor |
330 | 0 | )); |
331 | 11 | } |
332 | | |
333 | 11 | let mut encoder = Encoder::new(compression_level) |
334 | 11 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
335 | | // `CCtx::out_size()` guarantees a full compressed block always fits, so |
336 | | // the encoder never stalls for lack of output space within one `run`. |
337 | 11 | let mut out_buf = vec![0u8; zstd::zstd_safe::CCtx::out_size()]; |
338 | 11 | let mut received = 0; |
339 | | loop { |
340 | 26 | let chunk25 = raw_rx |
341 | 26 | .recv() |
342 | 26 | .await |
343 | 25 | .err_tip(|| "Failed to receive raw data in stream_encode_compressed_download")?0 ; |
344 | 25 | if chunk.is_empty() { |
345 | 10 | break; // EOF. |
346 | 15 | } |
347 | 15 | received += chunk.len() as u64; |
348 | 15 | let mut in_buffer = InBuffer::around(&chunk); |
349 | 33 | while in_buffer.pos() < in_buffer.src.len() { |
350 | 18 | let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice()); |
351 | 18 | encoder |
352 | 18 | .run(&mut in_buffer, &mut out_buffer) |
353 | 18 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
354 | 18 | let produced = out_buffer.as_slice(); |
355 | 18 | if !produced.is_empty() { |
356 | 11 | tx.send(Bytes::copy_from_slice(produced)).await?0 ; |
357 | 7 | } |
358 | | } |
359 | | } |
360 | | |
361 | 10 | if let Some(size_info5 ) = size_info { |
362 | 5 | size_info.validate_size(received)?2 ; |
363 | 5 | } |
364 | | |
365 | | // Finish the frame: flush any internally buffered compressed data plus |
366 | | // the frame epilogue. `finish` reports the bytes still pending, so loop |
367 | | // until it reports none. |
368 | | loop { |
369 | 8 | let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice()); |
370 | 8 | let remaining = encoder |
371 | 8 | .finish(&mut out_buffer, true) |
372 | 8 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
373 | 8 | let produced = out_buffer.as_slice(); |
374 | 8 | if !produced.is_empty() { |
375 | 8 | tx.send(Bytes::copy_from_slice(produced)).await?0 ; |
376 | 0 | } |
377 | 8 | if remaining == 0 { |
378 | 8 | break; |
379 | 0 | } |
380 | | } |
381 | | |
382 | 8 | tx.send_eof() |
383 | 8 | .err_tip(|| "Failed to send compressed download EOF")?0 ; |
384 | 8 | Ok(()) |
385 | 10 | } |