Skip to main content

nar_bridge/
nar.rs

1use axum::extract::Query;
2use axum::http::{Response, StatusCode};
3use axum::{body::Body, response::IntoResponse};
4use axum_extra::{TypedHeader, headers::Range};
5use axum_range::{KnownSize, Ranged};
6use bstr::ByteSlice;
7use futures::TryStreamExt;
8use nix_compat::{nix_http, nixbase32};
9use serde::Deserialize;
10use snix_castore::proto::parse_urlsafe_proto;
11use snix_store::nar::ingest_nar_and_hash;
12use std::io;
13use tokio_util::io::ReaderStream;
14use tracing::{Span, instrument, warn};
15
16use crate::AppState;
17
18#[derive(Debug, Deserialize)]
19pub(crate) struct GetNARParams {
20    #[serde(rename = "narsize")]
21    nar_size: Option<u64>,
22}
23
24#[instrument(skip_all)]
25pub async fn get_head(
26    method: axum::http::Method,
27    ranges: Option<TypedHeader<Range>>,
28    axum::extract::Path(root_node_enc): axum::extract::Path<String>,
29    axum::extract::Query(GetNARParams { nar_size }): Query<GetNARParams>,
30    axum::extract::State(AppState {
31        blob_service,
32        directory_service,
33        ..
34    }): axum::extract::State<AppState>,
35) -> Result<impl axum::response::IntoResponse, StatusCode> {
36    // We insist on the nar_size field being set. If the client dropped it from
37    // the NARInfo we sent, it's misbehaving and we reject it.
38    let nar_size = nar_size.ok_or_else(|| {
39        warn!("no nar_size parameter set");
40        StatusCode::BAD_REQUEST
41    })?;
42
43    // Attempt to parse the root node passed *by the user*
44    let root_node = parse_urlsafe_proto(root_node_enc).ok_or_else(|| {
45        warn!("unable to parse castore-infused NAR path");
46        StatusCode::NOT_FOUND
47    })?;
48
49    Ok((
50        // headers
51        [
52            ("cache-control", "max-age=31536000, immutable"),
53            ("content-type", nix_http::MIME_TYPE_NAR),
54        ],
55        if method == axum::http::Method::HEAD {
56            // If this is a HEAD request, construct a response returning back the
57            // user-provided content-length, but don't actually talk to castore.
58            // If the client lied about it, we will echo back a wrong `Content-Length`,
59            // which is their problem.
60            Response::builder()
61                .header("content-length", nar_size)
62                .body(Body::empty())
63                .unwrap()
64        } else if let Some(TypedHeader(ranges)) = ranges {
65            // If this is a range request, construct a seekable NAR reader.
66            let r =
67                snix_store::nar::seekable::Reader::new(root_node, blob_service, directory_service)
68                    .await
69                    .map_err(|e| {
70                        warn!(err=%e, "failed to construct seekable nar reader");
71                        StatusCode::INTERNAL_SERVER_ERROR
72                    })?;
73
74            // ensure the user-supplied nar size was correct, no point returning data otherwise.
75            if r.stream_len() != nar_size {
76                warn!(
77                    actual_nar_size = r.stream_len(),
78                    supplied_nar_size = nar_size,
79                    "wrong nar size supplied"
80                );
81                return Err(StatusCode::BAD_REQUEST);
82            }
83            Ranged::new(Some(ranges), KnownSize::sized(r, nar_size)).into_response()
84        } else {
85            // use the non-seekable codepath if there's no range(s) requested,
86            // as it uses less memory.
87            let (w, r) = tokio::io::duplex(1024 * 8);
88
89            // spawn a task rendering the NAR to the client.
90            tokio::spawn(async move {
91                if let Err(e) =
92                    snix_store::nar::write_nar(w, &root_node, blob_service, directory_service).await
93                {
94                    warn!(err=%e, "failed to write out NAR");
95                }
96            });
97
98            Response::builder()
99                // If the client lied about it, we will echo back a wrong `Content-Length`,
100                // which is their problem.
101                .header("content-length", nar_size)
102                .body(Body::from_stream(ReaderStream::new(r)))
103                .unwrap()
104        },
105    ))
106}
107
108/// Handler to respond to GET/HEAD requests for recently uploaded NAR files.
109/// Nix probes at {filehash}.nar[.compression_suffix] to determine whether a NAR
110/// has already been uploaded, by responding to (some of) these requests we
111/// avoid it unnecessarily uploading.
112/// We don't keep a full K/V from NAR hash to root note around, only the
113/// in-memory cache used to connect to the castore node when processing a PUT
114/// for the NARInfo.
115#[instrument(skip_all, fields(nar_str))]
116pub async fn head_root_nodes(
117    axum::extract::Path(nar_str): axum::extract::Path<String>,
118    axum::extract::State(AppState { root_nodes, .. }): axum::extract::State<AppState>,
119) -> Result<impl axum::response::IntoResponse, StatusCode> {
120    let (nar_hash, compression_suffix) =
121        nix_http::parse_nar_str(&nar_str).ok_or(StatusCode::UNAUTHORIZED)?;
122
123    // No paths with compression suffix are supported.
124    if !compression_suffix.is_empty() {
125        let compression_suffix = compression_suffix.as_bstr();
126        warn!(%compression_suffix, "invalid compression suffix");
127        return Err(StatusCode::UNAUTHORIZED);
128    }
129
130    // Check root_nodes, updating the moving it to the most recently used,
131    // as it might be referred in a subsequent NARInfo upload.
132    if root_nodes.write().get(&nar_hash).is_some() {
133        Ok("")
134    } else {
135        Err(StatusCode::NOT_FOUND)
136    }
137}
138
139#[instrument(skip_all)]
140pub async fn put(
141    axum::extract::Path(nar_str): axum::extract::Path<String>,
142    axum::extract::State(AppState {
143        blob_service,
144        directory_service,
145        root_nodes,
146        ..
147    }): axum::extract::State<AppState>,
148    request: axum::extract::Request,
149) -> Result<&'static str, StatusCode> {
150    let (nar_hash_expected, compression_suffix) =
151        nix_http::parse_nar_str(&nar_str).ok_or(StatusCode::UNAUTHORIZED)?;
152
153    // No paths with compression suffix are supported.
154    if !compression_suffix.is_empty() {
155        let compression_suffix = compression_suffix.as_bstr();
156        warn!(%compression_suffix, "invalid compression suffix");
157        return Err(StatusCode::UNAUTHORIZED);
158    }
159
160    let s = request.into_body().into_data_stream();
161
162    let mut r = tokio_util::io::StreamReader::new(s.map_err(|e| {
163        warn!(err=%e, "failed to read request body");
164        io::Error::new(io::ErrorKind::BrokenPipe, e.to_string())
165    }));
166
167    // ingest the NAR
168    let (root_node, nar_hash_actual, nar_size) = ingest_nar_and_hash(
169        blob_service.clone(),
170        directory_service.clone(),
171        &mut r,
172        &None,
173    )
174    .await
175    .map_err(io::Error::other)
176    .map_err(|e| {
177        warn!(err=%e, "failed to ingest nar");
178        StatusCode::INTERNAL_SERVER_ERROR
179    })?;
180
181    let s = Span::current();
182    s.record("nar_hash.expected", nixbase32::encode(&nar_hash_expected));
183    s.record("nar_size", nar_size);
184
185    if nar_hash_expected != nar_hash_actual {
186        warn!(
187            nar_hash.expected = nixbase32::encode(&nar_hash_expected),
188            nar_hash.actual = nixbase32::encode(&nar_hash_actual),
189            "nar hash mismatch"
190        );
191        return Err(StatusCode::BAD_REQUEST);
192    }
193
194    // store mapping of narhash to root node into root_nodes.
195    // we need it later to populate the root node when accepting the PathInfo.
196    root_nodes.write().put(nar_hash_actual, root_node);
197
198    Ok("")
199}
200
201#[cfg(test)]
202mod tests {
203    use std::{
204        num::NonZero,
205        sync::{Arc, LazyLock},
206    };
207
208    use axum::{Router, http::Method};
209    use bytes::Bytes;
210    use data_encoding::BASE64URL_NOPAD;
211    use nix_compat::nixbase32;
212    use sha2::Digest;
213    use snix_castore::{
214        blobservice::{BlobService, MemoryBlobService},
215        directoryservice::DirectoryService,
216        fixtures::HELLOWORLD_BLOB_DIGEST,
217        utils::gen_test_directory_service,
218    };
219    use snix_store::{
220        fixtures::{
221            CASTORE_NODE_COMPLICATED, CASTORE_NODE_SYMLINK, NAR_CONTENTS_COMPLICATED,
222            NAR_CONTENTS_HELLOWORLD, NAR_CONTENTS_SYMLINK,
223        },
224        pathinfoservice::PathInfoService,
225        utils::gen_test_pathinfo_service,
226    };
227    use tracing_test::traced_test;
228
229    use crate::AppState;
230
231    pub static NAR_STR_SYMLINK: LazyLock<String> = LazyLock::new(|| {
232        use prost::Message;
233        BASE64URL_NOPAD.encode(
234            &snix_castore::proto::Entry::from_name_and_node(
235                "".into(),
236                CASTORE_NODE_SYMLINK.clone(),
237            )
238            .encode_to_vec(),
239        )
240    });
241
242    /// Accepts a router without state, and returns a [axum_test::TestServer].
243    /// Also returns the underlying services, so they can be poked with during testing.
244    fn gen_server(
245        router: axum::Router<AppState>,
246    ) -> (
247        axum_test::TestServer,
248        impl BlobService,
249        impl DirectoryService,
250        impl PathInfoService,
251    ) {
252        let blob_service = Arc::new(MemoryBlobService::default());
253        let directory_service = Arc::new(gen_test_directory_service());
254        let path_info_service = Arc::new(gen_test_pathinfo_service());
255
256        let app = router.with_state(AppState::new(
257            blob_service.clone(),
258            directory_service.clone(),
259            path_info_service.clone(),
260            NonZero::new(100).unwrap(),
261        ));
262
263        (
264            axum_test::TestServer::new(app),
265            blob_service,
266            directory_service,
267            path_info_service,
268        )
269    }
270
271    #[traced_test]
272    #[tokio::test]
273    async fn test_get_head() {
274        let (server, _blob_service, _directory_service, _path_info_service) =
275            gen_server(Router::new().route(
276                "/nar/snix-castore/{root_node_enc}",
277                axum::routing::get(super::get_head),
278            ));
279
280        // Empty nar_str should be NotFound
281        server
282            .method(Method::HEAD, "/nar/snix-castore/")
283            .expect_failure()
284            .await
285            .assert_status_not_found();
286
287        let valid_url = &format!("/nar/snix-castore/{}", &*NAR_STR_SYMLINK);
288        let qps = &[("narsize", &NAR_CONTENTS_SYMLINK.len().to_string())];
289
290        // Missing narsize should be BadRequest
291        server
292            .method(Method::HEAD, valid_url)
293            .expect_failure()
294            .await
295            .assert_status_bad_request();
296
297        let invalid_url = {
298            use prost::Message;
299            let n = snix_castore::proto::Entry {
300                entry: Some(snix_castore::proto::entry::Entry::Directory(
301                    snix_castore::proto::DirectoryEntry {
302                        name: "".into(),
303                        digest: "invalid b64".into(),
304                        size: 1,
305                    },
306                )),
307            };
308            &format!(
309                "/nar/snix-castore/{}",
310                BASE64URL_NOPAD.encode(&n.encode_to_vec())
311            )
312        };
313
314        // Invalid node proto should return NotFound
315        server
316            .method(Method::HEAD, invalid_url)
317            .add_query_params(qps)
318            .expect_failure()
319            .await
320            .assert_status_not_found();
321
322        // success, HEAD
323        server
324            .method(Method::HEAD, valid_url)
325            .add_query_params(qps)
326            .expect_success()
327            .await;
328
329        // success, GET
330        assert_eq!(
331            NAR_CONTENTS_SYMLINK.as_slice(),
332            server
333                .get(valid_url)
334                .add_query_params(qps)
335                .expect_success()
336                .await
337                .into_bytes(),
338            "Expected to get back NAR_CONTENTS_SYMLINK"
339        )
340    }
341
342    /// Uploading a NAR with a different file hash than what's specified in the URL
343    /// is considered an error.
344    #[traced_test]
345    #[tokio::test]
346    async fn test_put_wrong_narhash() {
347        let (server, _blob_service, _directory_service, _path_info_service) =
348            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
349
350        server
351            .put("/nar/0000000000000000000000000000000000000000000000000000.nar")
352            .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
353            .expect_failure()
354            .await;
355    }
356
357    /// Uploading a NAR with compression is not supported.
358    #[traced_test]
359    #[tokio::test]
360    async fn test_put_with_compression_fail() {
361        let (server, _blob_service, _directory_service, _path_info_service) =
362            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
363
364        let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_SYMLINK.as_slice()).into();
365
366        let nar_url = format!("/nar/{}.nar.zst", nixbase32::encode(&nar_sha256));
367
368        server
369            .put(&nar_url)
370            .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
371            .expect_failure()
372            .await
373            .assert_status_unauthorized();
374    }
375
376    /// Upload a NAR with a single file, ensure the blob exists later on.
377    #[traced_test]
378    #[tokio::test]
379    async fn test_put_success() {
380        let (server, blob_service, _directory_service, _path_info_service) =
381            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
382
383        let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_HELLOWORLD.as_slice()).into();
384        let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
385
386        server
387            .put(&nar_url)
388            .bytes(Bytes::from_static(&NAR_CONTENTS_HELLOWORLD))
389            .expect_success()
390            .await;
391
392        assert!(
393            blob_service
394                .has(&HELLOWORLD_BLOB_DIGEST)
395                .await
396                .expect("blobservice")
397        )
398    }
399
400    // Upload a NAR with blobs and directories, ensure blobs and directories
401    // were uploaded, by rendering the NAR stream from the root node we know
402    // describes these contents.
403    #[traced_test]
404    #[tokio::test]
405    async fn test_put_success2() {
406        let (server, blob_service, directory_service, _path_info_service) =
407            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
408
409        let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_COMPLICATED.as_slice()).into();
410        let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
411
412        server
413            .put(&nar_url)
414            .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
415            .expect_success()
416            .await;
417
418        let mut buf = Vec::new();
419        snix_store::nar::write_nar(
420            &mut buf,
421            &CASTORE_NODE_COMPLICATED,
422            blob_service,
423            directory_service,
424        )
425        .await
426        .expect("write nar");
427
428        assert_eq!(NAR_CONTENTS_COMPLICATED, buf[..]);
429    }
430
431    /// Upload a NAR, ensure a HEAD by NarHash returns a 2xx code.
432    #[traced_test]
433    #[tokio::test]
434    async fn test_put_root_nodes() {
435        let (server, _blob_service, _directory_servicee, _path_info_service) = gen_server(
436            Router::new()
437                .route("/nar/{nar_str}", axum::routing::put(super::put))
438                .route("/nar/{nar_str}", axum::routing::get(super::head_root_nodes)),
439        );
440
441        let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_COMPLICATED.as_slice()).into();
442        let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
443
444        // upload NAR
445        server
446            .put(&nar_url)
447            .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
448            .expect_success()
449            .await;
450
451        // check HEAD by NarHash
452        server.method(Method::HEAD, &nar_url).expect_success().await;
453    }
454}