nar_bridge/
narinfo.rs

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