Coverage Report

Created: 2026-09-18 20:40

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 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
}