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