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_engine,
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_engine, 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_engine,
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) =
158        ingest_nar_and_hash(blob_engine, directory_service.clone(), &mut r, &None)
159            .await
160            .map_err(io::Error::other)
161            .map_err(|e| {
162                warn!(err=%e, "failed to ingest nar");
163                StatusCode::INTERNAL_SERVER_ERROR
164            })?;
165
166    let s = Span::current();
167    s.record("nar_hash.expected", nixbase32::encode(&nar_hash_expected));
168    s.record("nar_size", nar_size);
169
170    if nar_hash_expected != nar_hash_actual {
171        warn!(
172            nar_hash.expected = nixbase32::encode(&nar_hash_expected),
173            nar_hash.actual = nixbase32::encode(&nar_hash_actual),
174            "nar hash mismatch"
175        );
176        return Err(StatusCode::BAD_REQUEST);
177    }
178
179    // store mapping of narhash to root node into root_nodes.
180    // we need it later to populate the root node when accepting the PathInfo.
181    root_nodes.write().put(nar_hash_actual, root_node);
182
183    Ok("")
184}
185
186#[cfg(test)]
187mod tests {
188    use std::{
189        num::NonZero,
190        sync::{Arc, LazyLock},
191    };
192
193    use axum::{Router, http::Method};
194    use bytes::Bytes;
195    use data_encoding::BASE64URL_NOPAD;
196    use nix_compat::{nixbase32, nixhash::Sha256};
197    use snix_castore::{
198        blob_engine::BlobEngine,
199        directoryservice::DirectoryService,
200        fixtures::HELLOWORLD_BLOB_DIGEST,
201        utils::{gen_test_blob_engine, gen_test_directory_service},
202    };
203    use snix_store::{
204        fixtures::{
205            CASTORE_NODE_COMPLICATED, CASTORE_NODE_SYMLINK, NAR_CONTENTS_COMPLICATED,
206            NAR_CONTENTS_HELLOWORLD, NAR_CONTENTS_SYMLINK,
207        },
208        pathinfoservice::PathInfoService,
209        utils::gen_test_pathinfo_service,
210    };
211    use tracing_test::traced_test;
212
213    use crate::AppState;
214
215    pub static NAR_STR_SYMLINK: LazyLock<String> = LazyLock::new(|| {
216        use prost::Message;
217        BASE64URL_NOPAD.encode(
218            &snix_castore::proto::Entry::from_name_and_node(
219                "".into(),
220                CASTORE_NODE_SYMLINK.clone(),
221            )
222            .encode_to_vec(),
223        )
224    });
225
226    /// Accepts a router without state, and returns a [axum_test::TestServer].
227    /// Also returns the underlying services, so they can be poked with during testing.
228    fn gen_server(
229        router: axum::Router<AppState>,
230    ) -> (
231        axum_test::TestServer,
232        impl BlobEngine,
233        impl DirectoryService,
234        impl PathInfoService,
235    ) {
236        let blob_engine = Arc::new(gen_test_blob_engine());
237        let directory_service = Arc::new(gen_test_directory_service());
238        let path_info_service = Arc::new(gen_test_pathinfo_service());
239
240        let app = router.with_state(AppState::new(
241            blob_engine.clone(),
242            directory_service.clone(),
243            path_info_service.clone(),
244            NonZero::new(100).unwrap(),
245        ));
246
247        (
248            axum_test::TestServer::new(app),
249            blob_engine,
250            directory_service,
251            path_info_service,
252        )
253    }
254
255    #[traced_test]
256    #[tokio::test]
257    async fn test_get_head() {
258        let (server, _blob_engine, _directory_service, _path_info_service) =
259            gen_server(Router::new().route(
260                "/nar/snix-castore/{root_node_enc}",
261                axum::routing::get(super::get_head),
262            ));
263
264        // Empty nar_str should be NotFound
265        server
266            .method(Method::HEAD, "/nar/snix-castore/")
267            .expect_failure()
268            .await
269            .assert_status_not_found();
270
271        let valid_url = &format!("/nar/snix-castore/{}", *NAR_STR_SYMLINK);
272        let qps = &[("narsize", &NAR_CONTENTS_SYMLINK.len().to_string())];
273
274        // Missing narsize should be BadRequest
275        server
276            .method(Method::HEAD, valid_url)
277            .expect_failure()
278            .await
279            .assert_status_bad_request();
280
281        let invalid_url = {
282            use prost::Message;
283            let n = snix_castore::proto::Entry {
284                entry: Some(snix_castore::proto::entry::Entry::Directory(
285                    snix_castore::proto::DirectoryEntry {
286                        name: "".into(),
287                        digest: "invalid b64".into(),
288                        size: 1,
289                    },
290                )),
291            };
292            &format!(
293                "/nar/snix-castore/{}",
294                BASE64URL_NOPAD.encode(&n.encode_to_vec())
295            )
296        };
297
298        // Invalid node proto should return NotFound
299        server
300            .method(Method::HEAD, invalid_url)
301            .add_query_params(qps)
302            .expect_failure()
303            .await
304            .assert_status_not_found();
305
306        // success, HEAD
307        server
308            .method(Method::HEAD, valid_url)
309            .add_query_params(qps)
310            .expect_success()
311            .await;
312
313        // success, GET
314        assert_eq!(
315            NAR_CONTENTS_SYMLINK.as_slice(),
316            server
317                .get(valid_url)
318                .add_query_params(qps)
319                .expect_success()
320                .await
321                .into_bytes(),
322            "Expected to get back NAR_CONTENTS_SYMLINK"
323        )
324    }
325
326    /// Uploading a NAR with a different file hash than what's specified in the URL
327    /// is considered an error.
328    #[traced_test]
329    #[tokio::test]
330    async fn test_put_wrong_narhash() {
331        let (server, _blob_engine, _directory_service, _path_info_service) =
332            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
333
334        server
335            .put("/nar/0000000000000000000000000000000000000000000000000000.nar")
336            .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
337            .expect_failure()
338            .await;
339    }
340
341    /// Uploading a NAR with compression is not supported.
342    #[traced_test]
343    #[tokio::test]
344    async fn test_put_with_compression_fail() {
345        let (server, _blob_engine, _directory_service, _path_info_service) =
346            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
347
348        let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_SYMLINK.as_slice());
349
350        let nar_url = format!("/nar/{}.nar.zst", nixbase32::encode(&nar_sha256));
351
352        server
353            .put(&nar_url)
354            .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
355            .expect_failure()
356            .await
357            .assert_status_unauthorized();
358    }
359
360    /// Upload a NAR with a single file, ensure the blob exists later on.
361    #[traced_test]
362    #[tokio::test]
363    async fn test_put_success() {
364        let (server, blob_engine, _directory_service, _path_info_service) =
365            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
366
367        let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_HELLOWORLD.as_slice());
368        let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
369
370        server
371            .put(&nar_url)
372            .bytes(Bytes::from_static(&NAR_CONTENTS_HELLOWORLD))
373            .expect_success()
374            .await;
375
376        assert!(
377            blob_engine
378                .has(&HELLOWORLD_BLOB_DIGEST)
379                .await
380                .expect("blobengine to have blob")
381        )
382    }
383
384    // Upload a NAR with blobs and directories, ensure blobs and directories
385    // were uploaded, by rendering the NAR stream from the root node we know
386    // describes these contents.
387    #[traced_test]
388    #[tokio::test]
389    async fn test_put_success2() {
390        let (server, blob_engine, directory_service, _path_info_service) =
391            gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
392
393        let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_COMPLICATED.as_slice());
394        let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
395
396        server
397            .put(&nar_url)
398            .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
399            .expect_success()
400            .await;
401
402        let mut buf = Vec::new();
403        snix_store::nar::write_nar(
404            &mut buf,
405            &CASTORE_NODE_COMPLICATED,
406            &blob_engine,
407            &directory_service,
408        )
409        .await
410        .expect("write nar");
411
412        assert_eq!(NAR_CONTENTS_COMPLICATED, buf[..]);
413    }
414
415    /// Upload a NAR, ensure a HEAD by NarHash returns a 2xx code.
416    #[traced_test]
417    #[tokio::test]
418    async fn test_put_root_nodes() {
419        let (server, _blob_engine, _directory_servicee, _path_info_service) = gen_server(
420            Router::new()
421                .route("/nar/{nar_str}", axum::routing::put(super::put))
422                .route("/nar/{nar_str}", axum::routing::get(super::head_root_nodes)),
423        );
424
425        let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_COMPLICATED.as_slice());
426        let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
427
428        // upload NAR
429        server
430            .put(&nar_url)
431            .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
432            .expect_success()
433            .await;
434
435        // check HEAD by NarHash
436        server.method(Method::HEAD, &nar_url).expect_success().await;
437    }
438}