Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}