Coverage Report

Created: 2026-07-21 15:28

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-service/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
//! Wire compression utilities for REAPI compressed-blobs.
16
//!
17
//! This module handles compression/decompression of blob data on the gRPC wire
18
//! between client and server, per the REAPI compressed-blobs specification.
19
//! This is orthogonal to at-rest compression (`CompressionStore` with LZ4).
20
21
use std::collections::HashSet;
22
use std::io::Read;
23
24
use bytes::Bytes;
25
use nativelink_config::cas_server::{CapabilitiesConfig, InstanceName, WithInstanceName};
26
use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err};
27
use nativelink_proto::build::bazel::remote::execution::v2::compressor;
28
use nativelink_util::buf_channel::{DropCloserReadHalf, DropCloserWriteHalf};
29
use nativelink_util::common::DigestInfo;
30
use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc};
31
use nativelink_util::spawn_blocking;
32
use tracing::warn;
33
34
/// Zstd compression level for wire compression.
35
/// Level 0 in the zstd crate means "use default" (currently 3).
36
/// We use an explicit level for clarity.
37
pub const ZSTD_COMPRESSION_LEVEL: i32 = 3;
38
39
/// Upper bound on the buffer `decompress` reserves up front for a zstd blob.
40
/// `expected_size` comes from the client's claimed digest, so it must never be
41
/// used as an allocation hint directly: a small payload claiming a huge size
42
/// would otherwise force a large pre-emptive allocation before the real
43
/// decompressed length is ever known. We reserve `min(expected_size, this)`
44
/// so common payloads never reallocate while a hostile claim allocates at most
45
/// this. Sized comfortably above any honest `BatchUpdateBlobs` payload (the
46
/// only caller of this bulk path; large blobs stream through `ByteStream`).
47
const ZSTD_DECOMPRESS_PREALLOC_CAP: usize = 1024 * 1024;
48
49
/// Which instances accept and advertise REAPI compressed-blobs (zstd).
50
///
51
/// Derived from `CapabilitiesConfig.remote_cache_compression` so that
52
/// advertisement (capabilities) and acceptance (ByteStream/CAS) cannot drift.
53
#[derive(Debug, Clone, Default)]
54
pub struct RemoteCacheCompressionInstances(HashSet<InstanceName>);
55
56
impl RemoteCacheCompressionInstances {
57
    #[must_use]
58
3
    pub fn from_capabilities_configs(configs: &[WithInstanceName<CapabilitiesConfig>]) -> Self {
59
        Self(
60
3
            configs
61
3
                .iter()
62
3
                .filter(|config| config.remote_cache_compression)
63
3
                .map(|config| 
config.instance_name2
.
clone2
())
64
3
                .collect(),
65
        )
66
3
    }
67
68
    #[must_use]
69
13
    pub fn from_enabled_instance_names(
70
13
        instance_names: impl IntoIterator<Item = InstanceName>,
71
13
    ) -> Self {
72
13
        Self(instance_names.into_iter().collect())
73
13
    }
74
75
    #[must_use]
76
43
    pub fn enabled_for(&self, instance_name: &str) -> bool {
77
43
        self.0.contains(instance_name)
78
43
    }
79
}
80
81
/// Resolve a wire compressor from a URI compressor string (as it appears in
82
/// the `compressed-blobs/{compressor}/...` resource name) and validate it
83
/// against whether the instance supports remote cache compression.
84
///
85
/// Returns `compressor::Value::Identity` when `compressor_str` is `None` or
86
/// `"identity"`. This is the single source of truth for URI-to-compressor
87
/// parsing so that `ByteStream` and any other callers stay consistent.
88
39
pub fn resolve_wire_compressor(
89
39
    compressor_str: Option<&str>,
90
39
    remote_cache_compression_enabled: bool,
91
39
) -> Result<compressor::Value, Error> {
92
39
    match compressor_str {
93
24
        None | Some(
"identity"18
) => Ok(compressor::Value::Identity),
94
15
        Some("zstd") => {
95
14
            if remote_cache_compression_enabled {
96
11
                Ok(compressor::Value::Zstd)
97
            } else {
98
3
                Err(make_input_err!(
99
3
                    "Remote cache compression is not supported by this instance"
100
3
                ))
101
            }
102
        }
103
1
        Some(other) => Err(make_input_err!("Unsupported wire compressor: '{}'", other)),
104
    }
105
39
}
106
107
/// Compress data using the specified wire compressor.
108
///
109
/// `data` is the raw (uncompressed) bytes from the store.
110
/// Returns the compressed bytes suitable for sending on the wire.
111
7
pub fn compress(data: Bytes, compressor_value: compressor::Value) -> Result<Bytes, Error> {
112
7
    match compressor_value {
113
1
        compressor::Value::Identity => Ok(data),
114
        compressor::Value::Zstd => {
115
6
            let compressed = zstd::bulk::compress(&data, ZSTD_COMPRESSION_LEVEL)
116
6
                .map_err(|e| 
make_err!0
(
Code::Internal0
, "Zstd compression failed: {}", e))
?0
;
117
6
            Ok(Bytes::from(compressed))
118
        }
119
0
        _ => Err(make_input_err!(
120
0
            "Unsupported wire compressor for compression: {:?}",
121
0
            compressor_value
122
0
        )),
123
    }
124
7
}
125
126
/// Decompress data using the specified wire compressor.
127
///
128
/// `data` is the compressed bytes received from the wire.
129
/// `expected_size` is the uncompressed size (from the client's digest). It is
130
/// the hard cap on the decompressed output, but never a direct allocation
131
/// hint: the buffer grows with the real decoded bytes so a small payload
132
/// claiming a huge size cannot force a large up-front allocation.
133
/// Returns the decompressed bytes suitable for storing.
134
7
pub fn decompress(
135
7
    data: &[u8],
136
7
    compressor_value: compressor::Value,
137
7
    expected_size: usize,
138
7
) -> Result<Bytes, Error> {
139
7
    match compressor_value {
140
        compressor::Value::Identity => {
141
2
            if data.len() != expected_size {
142
1
                return Err(make_err!(
143
1
                    Code::InvalidArgument,
144
1
                    "Identity data size {} does not match expected size {}",
145
1
                    data.len(),
146
1
                    expected_size
147
1
                ));
148
1
            }
149
1
            Ok(Bytes::copy_from_slice(data))
150
        }
151
        compressor::Value::Zstd => {
152
            // Decode incrementally so `expected_size` (which is attacker
153
            // controlled — it is the client's claimed digest size) can bound
154
            // the output without being trusted as an allocation size. We
155
            // reserve only `min(expected_size, ZSTD_DECOMPRESS_PREALLOC_CAP)`,
156
            // then `take(expected_size + 1)` hard-caps the decoder so a
157
            // decompression bomb is rejected as soon as it overshoots. This
158
            // mirrors the real-byte-count validation the identity arm and the
159
            // streaming upload path already perform.
160
5
            let decoder = zstd::stream::read::Decoder::new(data)
161
5
                .map_err(|e| 
make_err!0
(
Code::InvalidArgument0
, "Zstd decompression failed: {e}"))
?0
;
162
5
            let mut output = Vec::with_capacity(expected_size.min(ZSTD_DECOMPRESS_PREALLOC_CAP));
163
            // `+ 1` lets an oversized stream produce one byte past the cap so
164
            // the size check below rejects it rather than silently truncating.
165
5
            let cap = u64::try_from(expected_size)
166
5
                .err_tip(|| "expected_size did not fit in u64")
?0
167
5
                .saturating_add(1);
168
5
            decoder
169
5
                .take(cap)
170
5
                .read_to_end(&mut output)
171
5
                .map_err(|e| 
make_err!0
(
Code::InvalidArgument0
, "Zstd decompression failed: {e}"))
?0
;
172
5
            if output.len() != expected_size {
173
3
                return Err(make_err!(
174
3
                    Code::InvalidArgument,
175
3
                    "Decompressed size {} does not match expected size {}",
176
3
                    output.len(),
177
3
                    expected_size
178
3
                ));
179
2
            }
180
2
            Ok(Bytes::from(output))
181
        }
182
0
        _ => Err(make_input_err!(
183
0
            "Unsupported wire compressor for decompression: {:?}",
184
0
            compressor_value
185
0
        )),
186
    }
187
7
}
188
189
#[must_use]
190
2
pub fn compress_for_batch_read(data: Bytes) -> (Bytes, compressor::Value) {
191
2
    match compress(data.clone(), compressor::Value::Zstd) {
192
2
        Ok(compressed) => {
193
2
            if compressed.len() < data.len() {
194
1
                (compressed, compressor::Value::Zstd)
195
            } else {
196
1
                (data, compressor::Value::Identity)
197
            }
198
        }
199
0
        Err(err) => {
200
0
            warn!("Wire compression failed, falling back to identity: {}", err);
201
0
            (data, compressor::Value::Identity)
202
        }
203
    }
204
2
}
205
206
6
fn validate_identity_batch_update(data: Bytes, expected_size: usize) -> Result<Bytes, Error> {
207
6
    if data.len() != expected_size {
208
0
        return Err(make_err!(
209
0
            Code::InvalidArgument,
210
0
            "Identity data size {} does not match expected size {}",
211
0
            data.len(),
212
0
            expected_size
213
0
        ));
214
6
    }
215
6
    Ok(data)
216
6
}
217
218
8
pub async fn decompress_batch_update(
219
8
    data: Bytes,
220
8
    compressor_i32: i32,
221
8
    expected_size: usize,
222
8
    remote_cache_compression_enabled: bool,
223
8
) -> Result<Bytes, Error> {
224
8
    let request_compressor = compressor::Value::try_from(compressor_i32)
225
8
        .map_err(|_| 
make_input_err!0
("Unknown compressor value: {}", compressor_i32))
?0
;
226
227
8
    match request_compressor {
228
6
        compressor::Value::Identity => validate_identity_batch_update(data, expected_size),
229
        compressor::Value::Zstd => {
230
2
            if !remote_cache_compression_enabled {
231
1
                return Err(make_input_err!(
232
1
                    "Remote cache compression is not supported by this instance"
233
1
                ));
234
1
            }
235
1
            spawn_blocking!("cas_decode_compressed_upload", move || {
236
1
                decompress(&data, compressor::Value::Zstd, expected_size)
237
1
            })
238
1
            .await
239
1
            .map_err(|e| 
make_err!0
(
Code::Internal0
, "Decompression task failed: {}", e))
?0
240
        }
241
0
        other => Err(make_input_err!("Unsupported wire compressor: {:?}", other)),
242
    }
243
8
}
244
245
/// Decode a client's zstd wire stream into raw bytes on `tx`, asynchronously.
246
///
247
/// Like [`stream_encode_compressed_download`], this must not occupy a tokio
248
/// blocking-pool thread for the stream's lifetime: the input arrives at the
249
/// client's upload pace and `tx` drains at the store's write pace, so a
250
/// blocking implementation parks a pool thread on whichever side is slower
251
/// for as long as the upload lasts. The zstd frame is consumed incrementally
252
/// with the raw streaming API instead; per-chunk decode cost is bounded by
253
/// the channel chunk size, so it runs inline on the async runtime with
254
/// channel-native backpressure on both sides.
255
///
256
/// Validation semantics match the REAPI compressed-blobs contract: the
257
/// decoded byte count may never exceed the digest size (checked per chunk so
258
/// a decompression bomb is rejected as soon as it overshoots), the final
259
/// count must equal it exactly, and the decoded bytes must hash to `digest`.
260
6
pub async fn stream_decode_compressed_upload(
261
6
    mut compressed_rx: DropCloserReadHalf,
262
6
    wire_compressor: compressor::Value,
263
6
    digest: DigestInfo,
264
6
    digest_function: DigestHasherFunc,
265
6
    mut tx: DropCloserWriteHalf,
266
6
) -> Result<(), Error> {
267
    use zstd::stream::raw::{Decoder, InBuffer, Operation, OutBuffer};
268
269
6
    if wire_compressor != compressor::Value::Zstd {
270
0
        return Err(make_input_err!(
271
0
            "Streaming upload decompression only supports zstd, got {:?}",
272
0
            wire_compressor
273
0
        ));
274
6
    }
275
276
6
    let expected_size = digest.size_bytes();
277
6
    let mut hasher = digest_function.hasher();
278
6
    let mut decoded_size = 0u64;
279
6
    let mut decoder = Decoder::new()
280
6
        .map_err(|e| 
make_err!0
(
Code::InvalidArgument0
, "Zstd decompression failed: {}", e))
?0
;
281
    // `DCtx::out_size()` guarantees a full decompressed block always fits, so
282
    // the decoder never stalls for lack of output space within one `run`.
283
6
    let mut out_buf = vec![0u8; zstd::zstd_safe::DCtx::out_size()];
284
    // Last input-size hint from the decoder: nonzero at input EOF means the
285
    // stream ended in the middle of a frame and must be rejected.
286
6
    let mut frame_input_hint = 0usize;
287
    loop {
288
17
        let 
chunk16
= compressed_rx
289
17
            .recv()
290
17
            .await
291
17
            .err_tip(|| "Failed to receive compressed data in stream_decode_compressed_upload")
?1
;
292
16
        if chunk.is_empty() {
293
5
            break; // EOF.
294
11
        }
295
11
        let mut in_buffer = InBuffer::around(&chunk);
296
        loop {
297
11
            let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice());
298
11
            frame_input_hint = decoder.run(&mut in_buffer, &mut out_buffer).map_err(|e| 
{0
299
0
                make_err!(Code::InvalidArgument, "Zstd decompression failed: {}", e)
300
0
            })?;
301
11
            let produced = Bytes::copy_from_slice(out_buffer.as_slice());
302
            // A completely full output buffer means the decoder may still
303
            // have buffered output to flush, even with no input left.
304
11
            let output_was_full = produced.len() == out_buf.len();
305
11
            if !produced.is_empty() {
306
5
                let produced_u64 = u64::try_from(produced.len())
307
5
                    .err_tip(|| "Decoded chunk size was not convertible to u64")
?0
;
308
5
                decoded_size = decoded_size.checked_add(produced_u64).ok_or_else(|| 
{0
309
0
                    make_err!(
310
0
                        Code::InvalidArgument,
311
                        "Decoded compressed upload size overflow"
312
                    )
313
0
                })?;
314
5
                if decoded_size > expected_size {
315
0
                    return Err(make_err!(
316
0
                        Code::InvalidArgument,
317
0
                        "Decoded compressed upload size {} bytes exceeds digest size {} bytes",
318
0
                        decoded_size,
319
0
                        expected_size
320
0
                    ));
321
5
                }
322
5
                hasher.update(&produced);
323
5
                tx.send(produced).await
?0
;
324
6
            }
325
11
            if in_buffer.pos() == in_buffer.src.len() && !output_was_full {
326
11
                break;
327
0
            }
328
        }
329
    }
330
5
    if frame_input_hint != 0 {
331
0
        return Err(make_err!(
332
0
            Code::InvalidArgument,
333
0
            "Compressed upload stream ended in the middle of a zstd frame"
334
0
        ));
335
5
    }
336
337
5
    if decoded_size != expected_size {
338
0
        return Err(make_err!(
339
0
            Code::InvalidArgument,
340
0
            "Decompressed size {} does not match expected size {}",
341
0
            decoded_size,
342
0
            expected_size
343
0
        ));
344
5
    }
345
5
    let actual_digest = hasher.finalize_digest();
346
5
    if actual_digest != digest {
347
1
        return Err(make_err!(
348
1
            Code::InvalidArgument,
349
1
            "Decompressed digest {} does not match expected digest {}",
350
1
            actual_digest,
351
1
            digest
352
1
        ));
353
4
    }
354
355
4
    tx.send_eof()
356
4
        .err_tip(|| "Failed to send decompressed upload EOF")
?0
;
357
4
    Ok(())
358
6
}
359
360
/// Encode a raw byte stream into a single zstd frame on `tx`, asynchronously.
361
///
362
/// This runs entirely on the async runtime and must never occupy a tokio
363
/// blocking-pool thread for the stream's lifetime: `tx` drains at the gRPC
364
/// client's pace, so a blocking implementation (blocking reads from `raw_rx`
365
/// plus `blocking_send` into `tx`) parks one pool thread per concurrent
366
/// compressed download until the client finishes. Enough concurrent downloads
367
/// with slow consumers then exhaust the blocking pool and starve every other
368
/// `spawn_blocking` user (filesystem store I/O, upload decode, credential
369
/// resolution). The zstd frame is instead produced incrementally with the raw
370
/// streaming API: the CPU cost per iteration is bounded by the channel chunk
371
/// size (small — micro/milliseconds), so it is acceptable inline on a worker
372
/// thread, and `tx.send(...).await` gives backpressure without a parked
373
/// thread.
374
///
375
/// The output is one well-formed zstd frame, identical in wire format to what
376
/// `zstd::stream::read::Encoder` produces (both drive `ZSTD_compressStream`
377
/// on a fresh `CCtx`).
378
11
pub async fn stream_encode_compressed_download(
379
11
    mut raw_rx: DropCloserReadHalf,
380
11
    wire_compressor: compressor::Value,
381
11
    mut tx: DropCloserWriteHalf,
382
11
) -> Result<(), Error> 
{6
383
    use zstd::stream::raw::{Encoder, InBuffer, Operation, OutBuffer};
384
385
6
    if wire_compressor != compressor::Value::Zstd {
386
0
        return Err(make_input_err!(
387
0
            "Streaming download compression only supports zstd, got {:?}",
388
0
            wire_compressor
389
0
        ));
390
6
    }
391
392
6
    let mut encoder = Encoder::new(ZSTD_COMPRESSION_LEVEL)
393
6
        .map_err(|e| 
make_err!0
(
Code::Internal0
, "Zstd compression failed: {}", e))
?0
;
394
    // `CCtx::out_size()` guarantees a full compressed block always fits, so
395
    // the encoder never stalls for lack of output space within one `run`.
396
6
    let mut out_buf = vec![0u8; zstd::zstd_safe::CCtx::out_size()];
397
    loop {
398
11
        let 
chunk10
= raw_rx
399
11
            .recv()
400
11
            .await
401
10
            .err_tip(|| "Failed to receive raw data in stream_encode_compressed_download")
?0
;
402
10
        if chunk.is_empty() {
403
3
            break; // EOF.
404
7
        }
405
7
        let mut in_buffer = InBuffer::around(&chunk);
406
12
        while in_buffer.pos() < in_buffer.src.len() {
407
7
            let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice());
408
7
            encoder
409
7
                .run(&mut in_buffer, &mut out_buffer)
410
7
                .map_err(|e| 
make_err!0
(
Code::Internal0
, "Zstd compression failed: {}", e))
?0
;
411
7
            let produced = out_buffer.as_slice();
412
7
            if !produced.is_empty() {
413
2
                tx.send(Bytes::copy_from_slice(produced)).await
?0
;
414
5
            }
415
        }
416
    }
417
418
    // Finish the frame: flush any internally buffered compressed data plus
419
    // the frame epilogue. `finish` reports the bytes still pending, so loop
420
    // until it reports none.
421
    loop {
422
3
        let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice());
423
3
        let remaining = encoder
424
3
            .finish(&mut out_buffer, true)
425
3
            .map_err(|e| 
make_err!0
(
Code::Internal0
, "Zstd compression failed: {}", e))
?0
;
426
3
        let produced = out_buffer.as_slice();
427
3
        if !produced.is_empty() {
428
3
            tx.send(Bytes::copy_from_slice(produced)).await
?0
;
429
0
        }
430
3
        if remaining == 0 {
431
3
            break;
432
0
        }
433
    }
434
435
3
    tx.send_eof()
436
3
        .err_tip(|| "Failed to send compressed download EOF")
?0
;
437
3
    Ok(())
438
3
}