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