Coverage Report

Created: 2026-10-26 21:04

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-store/src/verify_store.rs
Line
Count
Source
1
// Copyright 2024 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
use core::pin::Pin;
16
use std::sync::Arc;
17
18
use async_trait::async_trait;
19
use nativelink_config::stores::VerifySpec;
20
use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err};
21
use nativelink_metric::MetricsComponent;
22
use nativelink_util::buf_channel::{
23
    DropCloserReadHalf, DropCloserWriteHalf, make_buf_channel_pair,
24
};
25
use nativelink_util::common::PackedHash;
26
use nativelink_util::digest_hasher::{
27
    DigestHasher, DigestHasherFunc, digest_hasher_func_from_context,
28
};
29
use nativelink_util::health_utils::{HealthStatusIndicator, default_health_status_indicator};
30
use nativelink_util::metrics_utils::CounterWithTime;
31
use nativelink_util::store_trait::{
32
    RemoveCallback, Store, StoreDriver, StoreKey, StoreLike, UploadSizeInfo,
33
};
34
use tracing::warn;
35
36
#[derive(Debug, MetricsComponent)]
37
pub struct VerifyStore {
38
    #[metric(group = "inner_store")]
39
    inner_store: Store,
40
    #[metric(help = "If the verification store is verifying the size of the data")]
41
    verify_size: bool,
42
    #[metric(help = "If the verification store is verifying the hash of the data")]
43
    verify_hash: bool,
44
45
    // Metrics.
46
    #[metric(help = "Number of failures the verification store had due to size mismatches")]
47
    size_verification_failures: CounterWithTime,
48
    #[metric(help = "Number of failures the verification store had due to hash mismatches")]
49
    hash_verification_failures: CounterWithTime,
50
}
51
52
impl VerifyStore {
53
38
    pub fn new(spec: &VerifySpec, inner_store: Store) -> Arc<Self> {
54
38
        Arc::new(Self {
55
38
            inner_store,
56
38
            verify_size: spec.verify_size,
57
38
            verify_hash: spec.verify_hash,
58
38
            size_verification_failures: CounterWithTime::default(),
59
38
            hash_verification_failures: CounterWithTime::default(),
60
38
        })
61
38
    }
62
63
17
    async fn inner_check_update<D: DigestHasher>(
64
17
        &self,
65
17
        tx: &mut DropCloserWriteHalf,
66
17
        mut rx: DropCloserReadHalf,
67
17
        maybe_expected_digest_size: Option<u64>,
68
17
        original_hash: &PackedHash,
69
17
        digest_function: Option<DigestHasherFunc>,
70
17
        mut maybe_hasher: Option<&mut D>,
71
17
    ) -> Result<u64, Error> {
72
17
        let mut sum_size: u64 = 0;
73
        loop {
74
38
            let chunk = rx
75
38
                .recv()
76
38
                .await
77
38
                .err_tip(|| "Failed to read chunk in check_update in verify store")
?0
;
78
38
            sum_size += chunk.len() as u64;
79
80
            // Ensure if a user sends us too much data we fail quickly.
81
38
            if let Some(
expected_size28
) = maybe_expected_digest_size {
82
28
                match sum_size.cmp(&expected_size) {
83
                    core::cmp::Ordering::Greater => {
84
1
                        self.size_verification_failures.inc();
85
1
                        return Err(make_input_err!(
86
1
                            "Expected size {} but already received {} on insert",
87
1
                            expected_size,
88
1
                            sum_size
89
1
                        ));
90
                    }
91
                    core::cmp::Ordering::Equal => {
92
                        // Ensure our next chunk is the EOF chunk.
93
                        // If this was an error it'll be caught on the .recv()
94
                        // on next cycle.
95
12
                        if let Ok(eof_chunk) = rx.peek().await
96
12
                            && !eof_chunk.is_empty()
97
                        {
98
0
                            self.size_verification_failures.inc();
99
0
                            return Err(make_input_err!(
100
0
                                "Expected EOF chunk when exact size was hit on insert in verify store - {}",
101
0
                                expected_size,
102
0
                            ));
103
12
                        }
104
                    }
105
15
                    core::cmp::Ordering::Less => {}
106
                }
107
10
            }
108
109
            // If is EOF.
110
37
            if chunk.is_empty() {
111
13
                if let Some(
expected_size8
) = maybe_expected_digest_size
112
8
                    && sum_size != expected_size
113
                {
114
2
                    self.size_verification_failures.inc();
115
2
                    return Err(make_input_err!(
116
2
                        "Expected size {} but got size {} on insert",
117
2
                        expected_size,
118
2
                        sum_size
119
2
                    ));
120
11
                }
121
11
                if let Some(
hasher8
) = maybe_hasher.as_mut() {
122
8
                    let digest = hasher.finalize_digest();
123
8
                    let hash_result = digest.packed_hash();
124
8
                    if original_hash != hash_result {
125
2
                        self.hash_verification_failures.inc();
126
2
                        let Some(digest_function) = digest_function else {
127
0
                            return Err(make_input_err!(
128
0
                                "Hash verification failed without a digest function"
129
0
                            ));
130
                        };
131
2
                        return Err(make_input_err!(
132
2
                            "Hash verification using {digest_function} failed: client declared {digest_function}:{original_hash}, but the server computed {digest_function}:{hash_result}",
133
2
                        ));
134
6
                    }
135
3
                }
136
9
                tx.send_eof().err_tip(|| "In verify_store::check_update")
?0
;
137
9
                break;
138
24
            }
139
140
            // This will allows us to hash while sending data to another thread.
141
24
            let write_future = tx.send(chunk.clone());
142
143
24
            if let Some(
hasher9
) = maybe_hasher.as_mut() {
144
9
                hasher.update(chunk.as_ref());
145
15
            }
146
147
24
            write_future
148
24
                .await
149
24
                .err_tip(|| "Failed to write chunk to inner store in verify store")
?3
;
150
        }
151
9
        Ok(sum_size)
152
17
    }
153
}
154
155
impl VerifyStore {
156
43
    async fn inner_check_read(
157
43
        &self,
158
43
        mut rx: DropCloserReadHalf,
159
43
        writer: &mut DropCloserWriteHalf,
160
43
        expected: u64,
161
43
        key: StoreKey<'_>,
162
43
    ) -> Result<(), Error> {
163
43
        let mut sum_size: u64 = 0;
164
        loop {
165
74
            let 
chunk64
= rx
166
74
                .recv()
167
74
                .await
168
74
                .err_tip(|| "Failed to read chunk in check_read in verify store")
?10
;
169
64
            if chunk.is_empty() {
170
32
                if sum_size != expected {
171
17
                    self.size_verification_failures.inc();
172
17
                    return Err(make_err!(
173
17
                        Code::DataLoss,
174
17
                        "Read {sum_size} bytes of the {expected} expected for {key:?} in verify store"
175
17
                    ));
176
15
                }
177
15
                return writer.send_eof().err_tip(|| "In verify_store::check_read");
178
32
            }
179
32
            sum_size += chunk.len() as u64;
180
32
            if sum_size > expected {
181
1
                self.size_verification_failures.inc();
182
1
                return Err(make_err!(
183
1
                    Code::DataLoss,
184
1
                    "Read {sum_size} bytes, more than the {expected} expected for {key:?} in verify store"
185
1
                ));
186
31
            }
187
31
            writer
188
31
                .send(chunk)
189
31
                .await
190
31
                .err_tip(|| "Failed to forward chunk in verify store")
?0
;
191
        }
192
43
    }
193
}
194
195
#[async_trait]
196
impl StoreDriver for VerifyStore {
197
15
    async fn post_init(self: Arc<Self>) -> Result<(), Error> {
198
        self.inner_store.clone().into_inner().post_init().await?;
199
        Ok(())
200
15
    }
201
202
    async fn has_with_results(
203
        self: Pin<&Self>,
204
        digests: &[StoreKey<'_>],
205
        results: &mut [Option<u64>],
206
1
    ) -> Result<(), Error> {
207
        self.inner_store.has_with_results(digests, results).await
208
1
    }
209
210
    async fn update(
211
        self: Pin<&Self>,
212
        key: StoreKey<'_>,
213
        reader: DropCloserReadHalf,
214
        size_info: UploadSizeInfo,
215
17
    ) -> Result<u64, Error> {
216
        let StoreKey::Digest(digest) = key else {
217
            return Err(make_input_err!(
218
                "Only digests are supported in VerifyStore. Got {key:?}"
219
            ));
220
        };
221
        let digest_size = digest.size_bytes();
222
        if let UploadSizeInfo::ExactSize(expected_size) = size_info
223
            && self.verify_size
224
            && expected_size != digest_size
225
        {
226
            self.size_verification_failures.inc();
227
            return Err(make_input_err!(
228
                "Expected size to match. Got {} but digest says {} on update",
229
                expected_size,
230
                digest_size
231
            ));
232
        }
233
234
        let digest_function = if self.verify_hash {
235
            Some(digest_hasher_func_from_context())
236
        } else {
237
            None
238
        };
239
8
        let mut hasher = digest_function.map(|digest_function| digest_function.hasher());
240
241
        let maybe_digest_size = if self.verify_size {
242
            Some(digest_size)
243
        } else {
244
            None
245
        };
246
        let (tx, rx) = make_buf_channel_pair();
247
248
        let update_fut = self.inner_store.update(digest, rx, size_info);
249
17
        let check_fut = async move {
250
17
            let mut tx = tx;
251
17
            let result = self
252
17
                .inner_check_update(
253
17
                    &mut tx,
254
17
                    reader,
255
17
                    maybe_digest_size,
256
17
                    digest.packed_hash(),
257
17
                    digest_function,
258
17
                    hasher.as_mut(),
259
17
                )
260
17
                .await;
261
            // Inspect the channel before dropping it. Keep it in this future so
262
            // a validation failure also wakes the inner store's reader.
263
17
            (result, tx.is_pipe_broken())
264
17
        };
265
266
        let (update_res, (check_res, forwarding_failed)) = tokio::join!(update_fut, check_fut);
267
268
        match (update_res, check_res) {
269
            // A failed send is a consequence of the inner store's failure, not
270
            // a validation error. Preserve its code and both error messages.
271
            (Err(update_err), Err(check_err)) if forwarding_failed => {
272
                Err(update_err.merge(check_err))
273
            }
274
            // Size/hash or input-stream failures remain the checker's verdict.
275
            (_, Err(e)) | (Err(e), Ok(_)) => Err(e),
276
            (Ok(size), Ok(_)) => Ok(size),
277
        }
278
17
    }
279
280
    async fn get_part(
281
        self: Pin<&Self>,
282
        key: StoreKey<'_>,
283
        writer: &mut DropCloserWriteHalf,
284
        offset: u64,
285
        length: Option<u64>,
286
43
    ) -> Result<(), Error> {
287
        // A digest names its size, so a read that ends short of it is a
288
        // truncated blob, whatever tier below lost the tail: handed on as
289
        // EOF it would be cached as complete by every store above and fed
290
        // to actions as an input. The hash is not recomputed on reads.
291
        let expected = match &key {
292
            StoreKey::Digest(digest) if self.verify_size => {
293
                let to_end = digest.size_bytes().saturating_sub(offset);
294
3
                Some(length.map_or(to_end, |length| length.min(to_end)))
295
            }
296
            _ => None,
297
        };
298
        let Some(expected) = expected else {
299
            return self.inner_store.get_part(key, writer, offset, length).await;
300
        };
301
        let (mut tx, rx) = make_buf_channel_pair();
302
        // The writer half lives inside the read future, so an inner read that
303
        // fails after some data closes the channel as it returns; held out
304
        // here it kept the forwarding loop waiting for bytes that never came.
305
        let read_key = key.borrow().into_owned();
306
43
        let read_fut = async move {
307
43
            self.inner_store
308
43
                .get_part(read_key, &mut tx, offset, length)
309
43
                .await
310
43
        };
311
        let check_fut = self.inner_check_read(rx, writer, expected, key.borrow());
312
        let (read_res, check_res) = tokio::join!(read_fut, check_fut);
313
        // The checker's verdict first: on a read past the expected size it
314
        // drops its receiver and the inner send then fails with Internal,
315
        // which must not hide the DataLoss. Otherwise the inner error (a
316
        // NotFound from below) comes before the checker's closed-channel
317
        // Internal, as `update` orders the same pair.
318
        let result = match (read_res, check_res) {
319
            (_, Err(e)) if e.code == Code::DataLoss => Err(e),
320
            (Err(e), _) | (Ok(()), Err(e)) => Err(e),
321
            (Ok(()), Ok(())) => Ok(()),
322
        };
323
        if let Err(err) = &result
324
            && err.code == Code::DataLoss
325
        {
326
            // Keep the durable copy unless it is the wrong size too: a bad
327
            // cache copy must not cost the only good one. Read it back
328
            // rather than trust `has`, which for a filesystem store is the
329
            // size indexed at write time, not the file's.
330
            let inner = self.inner_store.as_store_driver_pin();
331
22
            let removed = async {
332
22
                let cached = inner.remove_cached(key.borrow()).await
?0
;
333
22
                let durable_is_bad = match &key {
334
22
                    StoreKey::Digest(digest) => {
335
22
                        let (mut tx, mut rx) = make_buf_channel_pair();
336
22
                        let read_key = key.borrow().into_owned();
337
22
                        let read_fut = async move {
338
22
                            self.inner_store.get_part(read_key, &mut tx, 0, None).await
339
22
                        };
340
22
                        let (read_res, drained) = tokio::join!(read_fut, rx.drain());
341
22
                        match read_res.and(drained) {
342
19
                            Ok(size) => size != digest.size_bytes(),
343
                            // Other errors mean unchecked, not bad.
344
3
                            Err(err) => err.code == Code::DataLoss,
345
                        }
346
                    }
347
0
                    StoreKey::Str(_) => false,
348
                };
349
22
                if durable_is_bad {
350
13
                    return Ok(inner.remove(key.borrow()).await
?0
||
cached1
);
351
9
                }
352
9
                Ok::<_, Error>(cached)
353
22
            }
354
            .await;
355
            match removed {
356
                Ok(removed) => warn!(
357
                    ?key,
358
                    removed, "Dropped a copy that failed size verification"
359
                ),
360
                Err(remove_err) => warn!(
361
                    ?key,
362
                    ?remove_err,
363
                    "Could not drop a copy that failed size verification"
364
                ),
365
            }
366
        }
367
        result
368
43
    }
369
370
0
    async fn remove(self: Pin<&Self>, key: StoreKey<'_>) -> Result<bool, Error> {
371
        self.inner_store.as_store_driver_pin().remove(key).await
372
0
    }
373
374
0
    async fn remove_cached(self: Pin<&Self>, key: StoreKey<'_>) -> Result<bool, Error> {
375
        self.inner_store
376
            .as_store_driver_pin()
377
            .remove_cached(key)
378
            .await
379
0
    }
380
381
6
    fn inner_store(&self, _digest: Option<StoreKey>) -> &'_ dyn StoreDriver {
382
6
        self
383
6
    }
384
385
5
    fn as_any<'a>(&'a self) -> &'a (dyn core::any::Any + Sync + Send + 'static) {
386
5
        self
387
5
    }
388
389
0
    fn as_any_arc(self: Arc<Self>) -> Arc<dyn core::any::Any + Sync + Send + 'static> {
390
0
        self
391
0
    }
392
393
0
    fn register_remove_callback(self: Arc<Self>, callback: RemoveCallback) -> Result<(), Error> {
394
0
        self.inner_store.register_remove_callback(callback)
395
0
    }
396
}
397
398
default_health_status_indicator!(VerifyStore);