Skip to main content

nar_bridge/
outhash.rs

1//* Handlers for $outhash.{narinfo,ls} paths.
2
3use axum::{http::StatusCode, response::IntoResponse};
4use bytes::Bytes;
5use nix_compat::{
6    narinfo::{NarInfo, Signature},
7    nix_http,
8};
9use snix_castore::proto::write_infused_nar_path;
10use snix_store::pathinfoservice::PathInfo;
11use tracing::{Span, instrument, warn};
12
13use crate::AppState;
14
15#[instrument(skip_all, fields(path_info.digest=tracing::field::Empty))]
16pub async fn head(
17    axum::extract::Path(p): axum::extract::Path<String>,
18    axum::extract::State(AppState {
19        path_info_service, ..
20    }): axum::extract::State<AppState>,
21) -> Result<impl IntoResponse, StatusCode> {
22    let (digest, _request_type) = nix_http::parse_outhash_str(&p).ok_or(StatusCode::NOT_FOUND)?;
23    Span::current().record("path_info.digest", &p[0..32]);
24
25    if path_info_service.has(digest).await.map_err(|e| {
26        warn!(err=%e, "failed to get PathInfo");
27        StatusCode::INTERNAL_SERVER_ERROR
28    })? {
29        Ok(([("content-type", nix_http::MIME_TYPE_NARINFO)], ""))
30    } else {
31        warn!("PathInfo not found");
32        Err(StatusCode::NOT_FOUND)
33    }
34}
35
36#[instrument(skip_all, fields(path_info.digest=tracing::field::Empty))]
37pub async fn get(
38    axum::extract::Path(p): axum::extract::Path<String>,
39    axum::extract::State(AppState {
40        directory_service,
41        path_info_service,
42        ..
43    }): axum::extract::State<AppState>,
44) -> Result<impl IntoResponse, StatusCode> {
45    let (digest, request_type) = nix_http::parse_outhash_str(&p).ok_or(StatusCode::NOT_FOUND)?;
46    Span::current().record("path_info.digest", &p[0..32]);
47
48    // fetch the PathInfo
49    let path_info = path_info_service
50        .get(digest)
51        .await
52        .map_err(|e| {
53            warn!(err=%e, "failed to get PathInfo");
54            StatusCode::INTERNAL_SERVER_ERROR
55        })?
56        .ok_or(StatusCode::NOT_FOUND)?;
57
58    match request_type {
59        nix_http::RequestType::Narinfo => Ok((
60            [("content-type", nix_http::MIME_TYPE_NARINFO)],
61            gen_narinfo_str(&path_info),
62        )),
63        nix_http::RequestType::Listing => {
64            // render the listing
65            let listing = snix_store::nar::produce_listing(&path_info.node, &directory_service)
66                .await
67                .map_err(|err| {
68                    warn!(%err, "failed to produce listing");
69                    StatusCode::INTERNAL_SERVER_ERROR
70                })?;
71
72            let listing_str = serde_json::to_string(&listing).map_err(|err| {
73                warn!(%err, "failed to serialize listing");
74                StatusCode::INTERNAL_SERVER_ERROR
75            })?;
76
77            Ok((
78                [("content-type", nix_http::MIME_TYPE_NAR_LISTING)],
79                listing_str,
80            ))
81        }
82    }
83}
84
85/// The size limit for NARInfo uploads nar-bridge receives
86const NARINFO_SIZE_LIMIT: usize = 2 * 1024 * 1024;
87
88#[instrument(skip_all, fields(path_info.digest=tracing::field::Empty))]
89pub async fn put(
90    axum::extract::Path(p): axum::extract::Path<String>,
91    axum::extract::State(AppState {
92        path_info_service,
93        root_nodes,
94        ..
95    }): axum::extract::State<AppState>,
96    request: axum::extract::Request,
97) -> Result<&'static str, StatusCode> {
98    let (digest, request_type) = nix_http::parse_outhash_str(&p).ok_or(StatusCode::NOT_FOUND)?;
99    Span::current().record("path_info.digest", &p[0..32]);
100
101    match request_type {
102        // rest of the function body
103        nix_http::RequestType::Narinfo => {}
104        nix_http::RequestType::Listing => {
105            // Nix might want to upload them, but we don't really care.
106            // FUTUREWORK: We could potentially compare what it uploads
107            // with what we synthesize and fail out if it's not identical.
108            // Right now we just pretend we uploaded and call it a day.
109            return Ok("");
110        }
111    }
112
113    let narinfo_bytes: Bytes = axum::body::to_bytes(request.into_body(), NARINFO_SIZE_LIMIT)
114        .await
115        .map_err(|e| {
116            warn!(err=%e, "unable to fetch body");
117            StatusCode::BAD_REQUEST
118        })?;
119
120    // Parse the narinfo from the body.
121    let narinfo_str = std::str::from_utf8(narinfo_bytes.as_ref()).map_err(|e| {
122        warn!(err=%e, "unable decode body as string");
123        StatusCode::BAD_REQUEST
124    })?;
125
126    let narinfo = NarInfo::parse(narinfo_str).map_err(|e| {
127        warn!(err=%e, "unable to parse narinfo");
128        StatusCode::BAD_REQUEST
129    })?;
130
131    if &digest != narinfo.store_path.digest() {
132        warn!("digest in URL doesn't match store path in NARInfo");
133        Err(StatusCode::BAD_REQUEST)?
134    }
135
136    // Lookup root node with peek, as we don't want to update the LRU list.
137    // We need to be careful to not hold the RwLock across the await point.
138    let maybe_root_node: Option<snix_castore::Node> =
139        root_nodes.read().peek(&narinfo.nar_hash).cloned();
140
141    match maybe_root_node {
142        Some(root_node) => {
143            // Persist the PathInfo.
144            path_info_service
145                .put(PathInfo {
146                    store_path: narinfo.store_path.to_owned(),
147                    node: root_node,
148                    references: narinfo.references.iter().map(|sp| sp.to_owned()).collect(),
149                    nar_sha256: narinfo.nar_hash,
150                    nar_size: narinfo.nar_size,
151                    signatures: narinfo
152                        .signatures
153                        .into_iter()
154                        .map(|s| {
155                            Signature::<String>::new(s.name().to_string(), s.bytes().to_owned())
156                        })
157                        .collect(),
158                    deriver: narinfo.deriver.as_ref().map(|sp| sp.to_owned()),
159                    ca: narinfo.ca,
160                })
161                .await
162                .map_err(|e| {
163                    warn!(err=%e, "failed to persist the PathInfo");
164                    StatusCode::INTERNAL_SERVER_ERROR
165                })?;
166
167            Ok("")
168        }
169        None => {
170            warn!("received narinfo with unknown NARHash");
171            Err(StatusCode::BAD_REQUEST)
172        }
173    }
174}
175
176/// Constructs a String in NARInfo format for the given [PathInfo].
177fn gen_narinfo_str(path_info: &PathInfo) -> String {
178    let mut narinfo = path_info.to_narinfo();
179    let mut url = String::new();
180    write_infused_nar_path(&mut url, path_info.node.clone(), narinfo.nar_size)
181        .expect("write into string");
182    narinfo.url = &url;
183
184    // Set FileSize to NarSize, as otherwise progress reporting in Nix looks very broken
185    narinfo.file_size = Some(narinfo.nar_size);
186
187    narinfo.to_string()
188}
189
190#[cfg(test)]
191mod tests {
192    use std::{num::NonZero, sync::Arc};
193
194    use axum::http::Method;
195    use nix_compat::nixbase32;
196    use snix_castore::{
197        blobservice::BlobService,
198        directoryservice::DirectoryService,
199        utils::{gen_test_blob_service, gen_test_directory_service},
200    };
201    use snix_store::{
202        fixtures::{DUMMY_PATH_DIGEST, NAR_CONTENTS_SYMLINK, PATH_INFO_SYMLINK},
203        path_info::PathInfo,
204        pathinfoservice::PathInfoService,
205        utils::gen_test_pathinfo_service,
206    };
207    use tracing_test::traced_test;
208
209    use crate::AppState;
210
211    /// Accepts a router without state, and returns a [axum_test::TestServer].
212    /// Also returns the underlying services, so they can be poked with during testing.
213    fn gen_server(
214        router: axum::Router<AppState>,
215    ) -> (
216        axum_test::TestServer,
217        impl BlobService,
218        impl DirectoryService,
219        impl PathInfoService,
220    ) {
221        let blob_service = Arc::new(gen_test_blob_service());
222        let directory_service = Arc::new(gen_test_directory_service());
223        let path_info_service = Arc::new(gen_test_pathinfo_service());
224
225        let app = router.with_state(AppState::new(
226            blob_service.clone(),
227            directory_service.clone(),
228            path_info_service.clone(),
229            NonZero::new(100).unwrap(),
230        ));
231
232        (
233            axum_test::TestServer::new(app),
234            blob_service,
235            directory_service,
236            path_info_service,
237        )
238    }
239
240    fn gen_nix_like_narinfo(path_info: &PathInfo) -> String {
241        let mut narinfo = path_info.to_narinfo();
242
243        let url = format!("nar/{}.nar", nixbase32::encode(&path_info.nar_sha256));
244        narinfo.url = &url;
245        narinfo.to_string()
246    }
247
248    /// HEAD and GET for a NARInfo for which there's no PathInfo should fail.
249    /// Same for the listing endpoint.
250    #[traced_test]
251    #[tokio::test]
252    async fn test_get_head_not_found() {
253        let (server, _blob_service, _directory_service, _path_info_service) =
254            gen_server(crate::gen_router(100));
255
256        let narinfo_url = &format!("{}.narinfo", nixbase32::encode(&DUMMY_PATH_DIGEST));
257        server
258            .method(Method::HEAD, narinfo_url)
259            .expect_failure()
260            .await
261            .assert_status_not_found();
262
263        server
264            .get(narinfo_url)
265            .expect_failure()
266            .await
267            .assert_status_not_found();
268
269        let listing_url = &format!("{}.ls", nixbase32::encode(&DUMMY_PATH_DIGEST));
270        server
271            .method(Method::HEAD, listing_url)
272            .expect_failure()
273            .await
274            .assert_status_not_found();
275        server
276            .get(listing_url)
277            .expect_failure()
278            .await
279            .assert_status_not_found();
280    }
281
282    /// HEAD and GET for a NARInfo for which there's a PathInfo stored succeeds.
283    /// Same for the listing endpoint.
284    #[traced_test]
285    #[tokio::test]
286    async fn test_get_head_found() {
287        let (server, _blob_service, _directory_service, path_info_service) =
288            gen_server(crate::gen_router(100));
289
290        let narinfo_url = &format!("{}.narinfo", nixbase32::encode(&DUMMY_PATH_DIGEST));
291        path_info_service
292            .put(PATH_INFO_SYMLINK.clone())
293            .await
294            .expect("put pathinfo");
295
296        server
297            .method(Method::HEAD, narinfo_url)
298            .expect_success()
299            .await
300            .assert_status_ok();
301
302        // Compare NARInfo
303        let narinfo_bytes = server.get(narinfo_url).expect_success().await.into_bytes();
304        assert_eq!(
305            super::gen_narinfo_str(&PATH_INFO_SYMLINK),
306            narinfo_bytes,
307            "expect NARInfo to match"
308        );
309
310        let listing_url = &format!("{}.ls", nixbase32::encode(&DUMMY_PATH_DIGEST));
311        server
312            .method(Method::HEAD, listing_url)
313            .expect_success()
314            .await
315            .assert_status_ok();
316
317        // Compare listing
318        let listing_bytes = server.get(listing_url).expect_success().await.into_bytes();
319        assert_eq!(
320            r#"{"root":{"target":"/nix/store/somewhereelse","type":"symlink"},"version":1}"#,
321            listing_bytes,
322            "expect listing to match"
323        );
324    }
325
326    /// Uploading a NARInfo without the NAR previously uploaded should fail.
327    #[traced_test]
328    #[tokio::test]
329    async fn test_put_without_prev_nar_fail() {
330        let (server, _blob_service, _directory_service, _path_info_service) =
331            gen_server(crate::gen_router(100));
332
333        // Produce a NARInfo the same way nix does.
334        // FUTUREWORK: add tests for NARInfo with unsupported formats
335        // (again referring with compression for example)
336        let narinfo_str = gen_nix_like_narinfo(&PATH_INFO_SYMLINK);
337
338        server
339            .put(&format!(
340                "{}.narinfo",
341                nixbase32::encode(&PATH_INFO_SYMLINK.nar_sha256)
342            ))
343            .text(narinfo_str)
344            .content_type(nix_compat::nix_http::MIME_TYPE_NARINFO)
345            .expect_failure()
346            .await;
347    }
348
349    // Upload a NAR, then a PathInfo referring to that upload.
350    #[traced_test]
351    #[tokio::test]
352    async fn test_upload_nar_then_narinfo() {
353        let (server, _blob_service, _directory_service, _path_info_service) =
354            gen_server(crate::gen_router(100));
355
356        // upload NAR
357        server
358            .put(&format!(
359                "/nar/{}.nar",
360                nixbase32::encode(&PATH_INFO_SYMLINK.nar_sha256)
361            ))
362            .bytes(NAR_CONTENTS_SYMLINK[..].into())
363            .expect_success()
364            .await;
365
366        let narinfo_str = gen_nix_like_narinfo(&PATH_INFO_SYMLINK);
367
368        // upload NARInfo
369        server
370            .put(&format!(
371                "/{}.narinfo",
372                nixbase32::encode(PATH_INFO_SYMLINK.store_path.digest())
373            ))
374            .text(narinfo_str)
375            .content_type(nix_compat::nix_http::MIME_TYPE_NARINFO)
376            .expect_success()
377            .await;
378    }
379}