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