Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-service/src/fetch_server.rs
Line
Count
Source
1
// Copyright 2025 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
use core::convert::Into;
16
use std::collections::HashMap;
17
use std::sync::Arc;
18
use std::time::{Duration, SystemTime};
19
20
use base64::Engine as _;
21
use base64::engine::general_purpose::STANDARD as BASE64;
22
use bytes::Bytes;
23
use nativelink_config::cas_server::{FetchConfig, FetchDownloaderConfig, WithInstanceName};
24
use nativelink_error::{Error, ResultExt, make_err, make_input_err};
25
use nativelink_proto::build::bazel::remote::asset::v1::fetch_server::{
26
    Fetch, FetchServer as Server,
27
};
28
use nativelink_proto::build::bazel::remote::asset::v1::{
29
    FetchBlobRequest, FetchBlobResponse, FetchDirectoryRequest, FetchDirectoryResponse,
30
};
31
use nativelink_proto::google::rpc::Status as GoogleStatus;
32
use nativelink_store::store_manager::StoreManager;
33
use nativelink_util::buf_channel::make_buf_channel_pair;
34
use nativelink_util::common::DigestInfo;
35
use nativelink_util::digest_hasher::{
36
    DigestHasher, DigestHasherFunc, default_digest_hasher_func, digest_hasher_func_from_context,
37
    make_ctx_for_hash_func,
38
};
39
use nativelink_util::store_trait::{Store, StoreLike, UploadSizeInfo};
40
use opentelemetry::context::FutureExt;
41
use prost::Message;
42
use reqwest::{Client, NoProxy, Proxy};
43
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
44
use tokio::sync::Semaphore;
45
use tonic::{Code, Request, Response, Status};
46
use tracing::{Instrument, Level, error_span, info, instrument};
47
48
use crate::remote_asset_proto::{RemoteAssetArtifact, RemoteAssetQuery};
49
50
#[derive(Debug, Clone)]
51
pub struct FetchStoreInfo {
52
    store: Store,
53
    downloader: Option<Arc<Downloader>>,
54
}
55
56
#[derive(Debug)]
57
struct ExpectedChecksum {
58
    function: DigestHasherFunc,
59
    hash: [u8; 32],
60
}
61
62
#[derive(Debug)]
63
struct Downloader {
64
    client: Client,
65
    proxy_client: Option<Client>,
66
    require_checksum: bool,
67
    allow_unknown_qualifiers: bool,
68
    cas: Store,
69
    max_size: Option<u64>,
70
    timeout: Duration,
71
    slots: Semaphore,
72
}
73
74
impl Downloader {
75
43
    fn new(config: &FetchDownloaderConfig, stores: &StoreManager) -> Result<Self, Error> {
76
43
        if config.http_proxy.is_some() && 
config.http_proxy_secret_file9
.
is_some9
() {
77
1
            return Err(make_input_err!("Set only one fetch proxy URL source"));
78
42
        }
79
42
        if config.max_concurrent_downloads > Semaphore::MAX_PERMITS {
80
0
            return Err(make_input_err!(
81
0
                "Fetch max_concurrent_downloads is too large"
82
0
            ));
83
42
        }
84
42
        let 
proxy_url41
= match &config.http_proxy_secret_file {
85
2
            Some(path) => Some(
86
2
                std::fs::read_to_string(path)
87
2
                    .map_err(|_| 
make_input_err!1
("Could not read fetch proxy secret file"))
?1
,
88
            ),
89
40
            None => config.http_proxy.clone(),
90
        };
91
        // Match the server's TLS provider, including memory-only store configurations.
92
41
        drop(rustls::crypto::ring::default_provider().install_default());
93
41
        let builder = Client::builder()
94
            // Redirect destinations must not receive credentials from the source URL.
95
41
            .referer(false)
96
41
            .no_proxy()
97
41
            .redirect(reqwest::redirect::Policy::limited(10));
98
41
        let 
proxy_client40
= if let Some(
url9
) = proxy_url {
99
9
            let url = url.trim();
100
8
            let parsed =
101
9
                reqwest::Url::parse(url).map_err(|_| 
make_input_err!1
("Invalid fetch proxy URL"))
?1
;
102
8
            if !matches!(parsed.scheme(), "http" | 
"https"0
) || parsed.host_str().is_none() {
103
0
                return Err(make_input_err!("Fetch proxy must be an HTTP(S) URL"));
104
8
            }
105
8
            let bypass = NoProxy::from_string(&config.no_proxy.join(","));
106
8
            let proxy = Proxy::all(url)
107
8
                .map_err(|_| 
make_input_err!0
("Invalid fetch proxy URL"))
?0
108
8
                .no_proxy(bypass);
109
            Some(
110
8
                Client::builder()
111
8
                    .referer(false)
112
8
                    .redirect(reqwest::redirect::Policy::limited(10))
113
8
                    .no_proxy()
114
8
                    .proxy(proxy)
115
8
                    .build()
116
8
                    .map_err(|_| 
make_input_err!0
("Could not build fetch proxy client"))
?0
,
117
            )
118
        } else {
119
32
            None
120
        };
121
40
        let cas = stores
122
40
            .get_store(&config.cas_store)
123
40
            .ok_or_else(|| 
make_input_err!0
("Fetch downloader CAS store does not exist"))
?0
;
124
        Ok(Self {
125
40
            client: builder
126
40
                .build()
127
40
                .map_err(|_| 
make_input_err!0
("Could not build fetch HTTP client"))
?0
,
128
40
            cas,
129
40
            proxy_client,
130
40
            require_checksum: config.require_checksum,
131
40
            allow_unknown_qualifiers: config.allow_unknown_qualifiers,
132
40
            max_size: (config.max_size_bytes != 0).then_some(config.max_size_bytes),
133
40
            timeout: Duration::from_secs(if config.timeout_s == 0 {
134
0
                300
135
            } else {
136
40
                config.timeout_s
137
            }),
138
40
            slots: Semaphore::new(if config.max_concurrent_downloads == 0 {
139
1
                4
140
            } else {
141
39
                config.max_concurrent_downloads
142
            }),
143
        })
144
43
    }
145
146
57
    async fn download(
147
57
        &self,
148
57
        uri: &str,
149
57
        checksum: Option<&[ExpectedChecksum]>,
150
57
    ) -> Result<DigestInfo, Error> {
151
57
        let default = Box::pin(self.download_with_client(&self.client, uri, checksum));
152
57
        let Some(
proxy9
) = &self.proxy_client else {
153
48
            return default.await;
154
        };
155
        // A failed path does not cancel an alternative that can still succeed.
156
9
        let attempts = vec![
157
9
            default,
158
9
            Box::pin(self.download_with_client(proxy, uri, checksum)),
159
        ];
160
9
        futures::future::select_ok(attempts)
161
9
            .await
162
9
            .map(|(digest, _)| digest)
163
56
    }
164
165
66
    async fn download_with_client(
166
66
        &self,
167
66
        client: &Client,
168
66
        uri: &str,
169
66
        checksum: Option<&[ExpectedChecksum]>,
170
66
    ) -> Result<DigestInfo, Error> {
171
66
        let url = reqwest::Url::parse(uri).map_err(|_| 
make_input_err!0
("Invalid fetch URI"))
?0
;
172
66
        if !matches!(url.scheme(), "http" | 
"https"0
) {
173
0
            return Err(make_input_err!("Only HTTP(S) fetch URIs are supported"));
174
66
        }
175
66
        let 
mut response56
= client
176
66
            .get(url)
177
66
            .send()
178
66
            .await
179
58
            .map_err(|_| 
make_err!0
(
Code::Unavailable0
, "Origin request failed"))
?0
180
58
            .error_for_status()
181
58
            .map_err(|_| 
make_err!2
(
Code::Unavailable2
, "Origin returned an HTTP error"))
?2
;
182
56
        if response.status() != reqwest::StatusCode::OK {
183
1
            return Err(make_err!(
184
1
                Code::Unavailable,
185
1
                "Origin did not return a complete object"
186
1
            ));
187
55
        }
188
55
        if self
189
55
            .max_size
190
55
            .is_some_and(|limit| 
response54
.
content_length54
().
is_some_and54
(|size|
size3
>
limit3
))
191
        {
192
0
            return Err(make_err!(
193
0
                Code::ResourceExhausted,
194
0
                "Fetch object exceeds max_size_bytes"
195
0
            ));
196
55
        }
197
        // Spool before publishing: no unverified object enters CAS, even without a checksum.
198
        // The anonymous file is removed on close, including timeout and cancellation.
199
55
        let file = nativelink_util::spawn_blocking!("fetch_spool_create", tempfile::tempfile)
200
55
            .await
201
55
            .map_err(|_| 
make_err!0
(
Code::Internal0
, "Could not create fetch spool"))
?0
?0
;
202
55
        let mut file = tokio::fs::File::from_std(file);
203
55
        let digest_function = digest_hasher_func_from_context();
204
55
        let mut hasher = digest_function.hasher();
205
55
        let mut checksum_hashers = Vec::new();
206
55
        for 
expected33
in checksum.into_iter().flatten() {
207
33
            if expected.function != digest_function
208
10
                && !checksum_hashers
209
10
                    .iter()
210
10
                    .any(|(function, _)| 
*function2
==
expected.function2
)
211
8
            {
212
8
                checksum_hashers.push((expected.function, expected.function.hasher()));
213
25
            }
214
        }
215
55
        let mut size = 0_u64;
216
1.28k
        while let Some(
chunk1.23k
) = response
217
1.28k
            .chunk()
218
1.28k
            .await
219
1.28k
            .map_err(|_| 
make_err!0
(
Code::Unavailable0
, "Origin body transfer failed"))
?0
220
        {
221
1.23k
            size = size
222
1.23k
                .checked_add(chunk.len() as u64)
223
1.23k
                .filter(|n| self.max_size.is_none_or(|limit| 
*n53
<=
limit53
))
224
1.23k
                .ok_or_else(|| 
{1
225
1
                    make_err!(
226
1
                        Code::ResourceExhausted,
227
                        "Fetch object exceeds max_size_bytes"
228
                    )
229
1
                })?;
230
1.23k
            hasher.update(&chunk);
231
1.23k
            for (_, 
hasher8
) in &mut checksum_hashers {
232
8
                hasher.update(&chunk);
233
8
            }
234
1.23k
            file.write_all(&chunk).await
?0
;
235
        }
236
54
        let digest = hasher.finalize_digest();
237
54
        if let Some(
alternatives24
) = checksum {
238
24
            let checksum_digests: Vec<_> = checksum_hashers
239
24
                .into_iter()
240
24
                .map(|(function, mut hasher)| (
function8
,
hasher8
.
finalize_digest8
()))
241
24
                .collect();
242
33
            let 
matches24
=
alternatives.iter()24
.
any24
(|expected| {
243
33
                let actual = if expected.function == digest_function {
244
23
                    Some(&digest)
245
                } else {
246
10
                    checksum_digests
247
10
                        .iter()
248
10
                        .find(|(function, _)| *function == expected.function)
249
10
                        .map(|(_, digest)| digest)
250
                };
251
33
                actual.is_some_and(|actual| {
252
33
                    actual.packed_hash().as_slice() == expected.hash.as_slice()
253
33
                })
254
33
            });
255
24
            if !matches {
256
8
                return Err(make_err!(Code::DataLoss, "Fetch checksum mismatch"));
257
16
            }
258
30
        }
259
46
        file.rewind().await
?0
;
260
46
        let (mut tx, rx) = make_buf_channel_pair();
261
46
        let replay = async {
262
46
            let mut buffer = vec![0; 64 * 1024];
263
            loop {
264
4.18k
                let 
count4.18k
= file.read(&mut buffer).await
?0
;
265
4.18k
                if count == 0 {
266
45
                    break;
267
4.14k
                }
268
4.14k
                tx.send(Bytes::copy_from_slice(&buffer[..count])).await
?0
;
269
            }
270
45
            tx.send_eof()
271
45
        };
272
46
        futures::try_join!(
273
46
            replay,
274
46
            self.cas.update(digest, rx, UploadSizeInfo::ExactSize(size))
275
0
        )?;
276
45
        Ok(digest)
277
57
    }
278
}
279
280
#[derive(Debug, Clone)]
281
pub struct FetchServer {
282
    stores: HashMap<String, FetchStoreInfo>,
283
}
284
285
impl FetchServer {
286
47
    pub fn new(
287
47
        configs: &[WithInstanceName<FetchConfig>],
288
47
        store_manager: &StoreManager,
289
47
    ) -> Result<Self, Error> {
290
47
        let mut stores = HashMap::with_capacity(configs.len());
291
48
        for config in 
configs47
{
292
48
            let store = store_manager
293
48
                .get_store(&config.fetch_store)
294
48
                .ok_or_else(|| 
{0
295
0
                    make_input_err!("'fetch_store': '{}' does not exist", config.fetch_store)
296
0
                })?;
297
48
            let 
downloader45
= config
298
48
                .downloader
299
48
                .as_ref()
300
48
                .map(|spec| 
Downloader::new43
(
spec43
,
store_manager43
).
map43
(Arc::new))
301
48
                .transpose()
?3
;
302
45
            stores.insert(
303
45
                config.instance_name.clone(),
304
45
                FetchStoreInfo { store, downloader },
305
            );
306
        }
307
44
        Ok(Self { stores })
308
47
    }
309
310
0
    pub fn into_service(self) -> Server<Self> {
311
0
        Server::new(self)
312
0
    }
313
314
99
    async fn inner_fetch_blob(
315
99
        &self,
316
99
        request: FetchBlobRequest,
317
99
    ) -> Result<Response<FetchBlobResponse>, Error> {
318
99
        let instance_name = &request.instance_name;
319
99
        let store_info = self.stores.get(instance_name).ok_or_else(|| 
{0
320
0
            make_input_err!("'instance_name' not configured for '{instance_name}'")
321
0
        })?;
322
323
99
        if request.uris.is_empty() {
324
0
            return Err(Error::new(
325
0
                Code::InvalidArgument,
326
0
                "No uris in fetch request".to_owned(),
327
0
            ));
328
99
        }
329
99
        let 
download_options83
= if let Some(
downloader94
) = &store_info.downloader {
330
94
            let 
checksum78
= Self::checksum(&request, downloader)
?16
;
331
78
            let timeout = match request.timeout {
332
2
                Some(value) => Duration::try_from(value)
333
2
                    .map_err(|_| 
make_input_err!0
("Invalid fetch timeout"))
?0
334
2
                    .min(downloader.timeout),
335
76
                None => downloader.timeout,
336
            };
337
78
            Some((downloader, checksum, timeout))
338
        } else {
339
5
            None
340
        };
341
84
        for uri in 
&request.uris83
{
342
84
            let asset_request = RemoteAssetQuery::new(uri.clone(), request.qualifiers.clone());
343
84
            let asset_digest = asset_request.digest();
344
84
            let asset_response_possible = store_info
345
84
                .store
346
84
                .get_part_unchunked(asset_digest, 0, None)
347
84
                .await;
348
349
84
            info!(
350
84
                digest = format!("{}", asset_digest),
351
                "Looked up fetch asset"
352
            );
353
354
84
            if let Ok(
asset_response_raw34
) = asset_response_possible {
355
34
                let 
asset_response33
= RemoteAssetArtifact::decode(asset_response_raw)
356
34
                    .err_tip(|| "Failed to decode stored RemoteAssetArtifact")
?1
;
357
33
                if let Some(
downloader30
) = &store_info.downloader {
358
                    // Legacy index keys concatenate fields without boundaries. A matching
359
                    // key alone does not prove that the record belongs to this request.
360
30
                    if asset_response.uri != asset_request.uri
361
29
                        || asset_response.qualifiers != asset_request.qualifiers
362
                    {
363
2
                        continue;
364
28
                    }
365
28
                    let now: prost_types::Timestamp = SystemTime::now().into();
366
28
                    let unexpired = asset_response.expire_at.as_ref().is_none_or(|expiry| 
{0
367
0
                        (expiry.seconds, expiry.nanos) > (now.seconds, now.nanos)
368
0
                    });
369
28
                    let stored_function =
370
28
                        DigestHasherFunc::try_from(asset_response.digest_function)
?0
;
371
28
                    let same_function = stored_function == digest_hasher_func_from_context();
372
28
                    let fresh_enough =
373
28
                        request
374
28
                            .oldest_content_accepted
375
28
                            .as_ref()
376
28
                            .is_none_or(|oldest| 
{7
377
7
                                asset_response.fetched_at.as_ref().is_some_and(|fetched| 
{6
378
6
                                    (fetched.seconds, fetched.nanos)
379
6
                                        >= (oldest.seconds, oldest.nanos)
380
6
                                })
381
7
                            });
382
28
                    if !unexpired || !same_function || !fresh_enough {
383
4
                        continue;
384
24
                    }
385
24
                    let Some(blob) = asset_response.blob_digest.as_ref() else {
386
0
                        continue;
387
                    };
388
24
                    if downloader
389
24
                        .cas
390
24
                        .has(DigestInfo::try_from(blob)
?0
)
391
24
                        .await
?0
392
24
                        .is_none()
393
                    {
394
2
                        continue;
395
22
                    }
396
3
                }
397
25
                return Ok(Response::new(FetchBlobResponse {
398
25
                    status: Some(GoogleStatus {
399
25
                        code: Code::Ok.into(),
400
25
                        message: "Fetch object found".to_owned(),
401
25
                        details: vec![],
402
25
                    }),
403
25
                    uri: asset_response.uri,
404
25
                    qualifiers: asset_response.qualifiers,
405
25
                    expires_at: asset_response.expire_at,
406
25
                    blob_digest: asset_response.blob_digest,
407
25
                    digest_function: asset_response.digest_function,
408
25
                }));
409
50
            }
410
        }
411
57
        let Some((
downloader56
,
checksum56
,
timeout56
)) = download_options else {
412
1
            let mut response =
413
1
                Self::miss_response(&request, make_err!(Code::NotFound, "No item found"));
414
1
            response.digest_function = default_digest_hasher_func().proto_digest_func().into();
415
1
            return Ok(Response::new(response));
416
        };
417
56
        let retrieve = async {
418
56
            let _slot = downloader
419
56
                .slots
420
56
                .acquire()
421
56
                .await
422
56
                .map_err(|_| 
make_err!0
(
Code::Unavailable0
, "Fetch downloader closed"))
?0
;
423
56
            let mut last_error = make_err!(Code::NotFound, "No downloadable URI found");
424
57
            for uri in 
&request.uris56
{
425
57
                let fetched_at = SystemTime::now().into();
426
57
                match downloader.download(uri, checksum.as_deref()).await {
427
45
                    Ok(digest) => {
428
45
                        let mut artifact = RemoteAssetArtifact::new(
429
45
                            uri.clone(),
430
45
                            request.qualifiers.clone(),
431
45
                            digest.into(),
432
45
                            None,
433
45
                            digest_hasher_func_from_context().proto_digest_func().into(),
434
                        );
435
45
                        artifact.fetched_at = Some(fetched_at);
436
45
                        store_info
437
45
                            .store
438
45
                            .update_oneshot(artifact.digest(), artifact.as_bytes())
439
45
                            .await
?0
;
440
45
                        return Ok(Response::new(FetchBlobResponse {
441
45
                            status: Some(GoogleStatus {
442
45
                                code: 0,
443
45
                                message: "Fetch object created".into(),
444
45
                                details: vec![],
445
45
                            }),
446
45
                            uri: artifact.uri,
447
45
                            qualifiers: artifact.qualifiers,
448
45
                            expires_at: artifact.expire_at,
449
45
                            blob_digest: artifact.blob_digest,
450
45
                            digest_function: artifact.digest_function,
451
45
                        }));
452
                    }
453
11
                    Err(error) => last_error = error,
454
                }
455
            }
456
10
            Ok(Response::new(Self::miss_response(&request, last_error)))
457
55
        };
458
56
        tokio::time::timeout(timeout, retrieve)
459
56
            .await
460
56
            .unwrap_or_else(|_| 
{1
461
1
                Ok(Response::new(Self::miss_response(
462
1
                    &request,
463
1
                    make_err!(Code::DeadlineExceeded, "Fetch origin timeout exceeded"),
464
1
                )))
465
1
            })
466
99
    }
467
468
94
    fn checksum(
469
94
        request: &FetchBlobRequest,
470
94
        downloader: &Downloader,
471
94
    ) -> Result<Option<Vec<ExpectedChecksum>>, Error> {
472
94
        let mut checksum = None;
473
94
        for 
qualifier60
in &request.qualifiers {
474
60
            if qualifier.name.starts_with("http_header:")
475
57
                || qualifier.name.starts_with("http_header_url:")
476
            {
477
5
                return Err(make_input_err!(
478
5
                    "HTTP header qualifiers are not supported by the fetch downloader"
479
5
                ));
480
55
            }
481
55
            if qualifier.name != "checksum.sri" {
482
11
                if qualifier.name != "bazel.canonical_id" && !downloader.allow_unknown_qualifiers {
483
2
                    return Err(make_input_err!("Unsupported fetch qualifier"));
484
9
                }
485
9
                continue;
486
44
            }
487
44
            if checksum.is_some() {
488
1
                return Err(make_input_err!("Duplicate checksum qualifier"));
489
43
            }
490
43
            let mut alternatives = Vec::new();
491
59
            for token in 
qualifier.value.split_whitespace()43
{
492
59
                let (
algorithm58
,
encoded58
) = token
493
59
                    .split_once('-')
494
59
                    .ok_or_else(|| 
make_input_err!1
("Invalid checksum.sri format"))
?1
;
495
58
                if algorithm.is_empty() || encoded.is_empty() {
496
0
                    return Err(make_input_err!("Invalid checksum.sri format"));
497
58
                }
498
58
                let 
hash56
= BASE64
499
58
                    .decode(encoded)
500
58
                    .map_err(|_| 
make_input_err!2
("Invalid checksum.sri encoding"))
?2
;
501
                // Unsupported algorithms may coexist with supported alternatives.
502
56
                let Ok(
function54
) = DigestHasherFunc::try_from(algorithm) else {
503
2
                    continue;
504
                };
505
54
                let 
hash53
= hash
506
54
                    .try_into()
507
54
                    .map_err(|_| 
make_input_err!1
("Invalid checksum.sri length"))
?1
;
508
53
                alternatives.push(ExpectedChecksum { function, hash });
509
            }
510
39
            if alternatives.is_empty() {
511
3
                return Err(make_input_err!("No supported checksum.sri alternative"));
512
36
            }
513
36
            checksum = Some(alternatives);
514
        }
515
79
        if downloader.require_checksum && 
checksum2
.
is_none2
() {
516
1
            return Err(make_input_err!("Fetch downloader requires checksum.sri"));
517
78
        }
518
78
        Ok(checksum)
519
94
    }
520
521
12
    fn miss_response(request: &FetchBlobRequest, error: Error) -> FetchBlobResponse {
522
12
        FetchBlobResponse {
523
12
            status: Some(error.into()),
524
12
            uri: request.uris.first().cloned().unwrap_or_default(),
525
12
            qualifiers: vec![],
526
12
            expires_at: None,
527
12
            blob_digest: None,
528
12
            digest_function: digest_hasher_func_from_context().proto_digest_func().into(),
529
12
        }
530
12
    }
531
}
532
533
#[tonic::async_trait]
534
impl Fetch for FetchServer {
535
    #[allow(clippy::blocks_in_conditions)]
536
    #[instrument(
537
        err(level = Level::WARN),
538
        skip_all,
539
        fields(
540
            uri_count = grpc_request.get_ref().uris.len(),
541
            qualifier_count = grpc_request.get_ref().qualifiers.len(),
542
            digest_function = grpc_request.get_ref().digest_function,
543
        )
544
    )]
545
    async fn fetch_blob(
546
        &self,
547
        grpc_request: Request<FetchBlobRequest>,
548
    ) -> Result<Response<FetchBlobResponse>, Status> {
549
        let request = grpc_request.into_inner();
550
        let digest_function = request.digest_function;
551
        self.inner_fetch_blob(request)
552
            .instrument(error_span!("fetch_server_fetch_blob"))
553
            .with_context(
554
                make_ctx_for_hash_func(digest_function).err_tip(|| "In FetchServer::fetch_blob")?,
555
            )
556
            .await
557
            .err_tip(|| "Failed on fetch_blob() command")
558
            .map_err(Into::into)
559
    }
560
561
    #[allow(clippy::blocks_in_conditions)]
562
    #[instrument(
563
        err(level = Level::WARN),
564
        skip_all,
565
        fields(
566
            uri_count = _grpc_request.get_ref().uris.len(),
567
            qualifier_count = _grpc_request.get_ref().qualifiers.len(),
568
            digest_function = _grpc_request.get_ref().digest_function,
569
        )
570
    )]
571
    async fn fetch_directory(
572
        &self,
573
        _grpc_request: Request<FetchDirectoryRequest>,
574
    ) -> Result<Response<FetchDirectoryResponse>, Status> {
575
        Err(Status::unimplemented("FetchDirectory not implemented"))
576
    }
577
}