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    fn gen_server(
175        router: axum::Router<AppState>,
176    ) -> (
177        axum_test::TestServer,
178        impl BlobService,
179        impl DirectoryService,
180        impl PathInfoService,
181    ) {
182        let blob_service = Arc::new(MemoryBlobService::default());
183        let directory_service = Arc::new(gen_test_directory_service());
184        let path_info_service = Arc::new(gen_test_pathinfo_service());
185
186        let app = router.with_state(AppState::new(
187            blob_service.clone(),
188            directory_service.clone(),
189            path_info_service.clone(),
190            NonZero::new(100).unwrap(),
191        ));
192
193        (
194            axum_test::TestServer::new(app).unwrap(),
195            blob_service,
196            directory_service,
197            path_info_service,
198        )
199    }
200
201    fn gen_nix_like_narinfo(path_info: &PathInfo) -> String {
202        let mut narinfo = path_info.to_narinfo();
203
204        let url = format!("nar/{}.nar", nixbase32::encode(&path_info.nar_sha256));
205        narinfo.url = &url;
206        narinfo.to_string()
207    }
208
209    /// HEAD and GET for a NARInfo for which there's no PathInfo should fail.
210    #[traced_test]
211    #[tokio::test]
212    async fn test_get_head_not_found() {
213        let (server, _blob_service, _directory_service, _path_info_service) =
214            gen_server(crate::gen_router(100));
215
216        let url = &format!("{}.narinfo", nixbase32::encode(&DUMMY_PATH_DIGEST));
217
218        // HEAD
219        server
220            .method(Method::HEAD, url)
221            .expect_failure()
222            .await
223            .assert_status_not_found();
224
225        // GET
226        server
227            .get(url)
228            .expect_failure()
229            .await
230            .assert_status_not_found();
231    }
232
233    /// HEAD and GET for a NARInfo for which there's a PathInfo stored succeeds.
234    #[traced_test]
235    #[tokio::test]
236    async fn test_get_head_found() {
237        let (server, _blob_service, _directory_service, path_info_service) =
238            gen_server(crate::gen_router(100));
239
240        let url = &format!("{}.narinfo", nixbase32::encode(&DUMMY_PATH_DIGEST));
241
242        path_info_service
243            .put(PATH_INFO.clone())
244            .await
245            .expect("put pathinfo");
246
247        server
248            .method(Method::HEAD, url)
249            .expect_success()
250            .await
251            .assert_status_ok();
252
253        // GET
254        let narinfo_bytes = server.get(url).expect_success().await.into_bytes();
255
256        assert_eq!(crate::narinfo::gen_narinfo_str(&PATH_INFO), narinfo_bytes);
257    }
258
259    /// Uploading a NARInfo without the NAR previously uploaded should fail.
260    #[traced_test]
261    #[tokio::test]
262    async fn test_put_without_prev_nar_fail() {
263        let (server, _blob_service, _directory_service, _path_info_service) =
264            gen_server(crate::gen_router(100));
265
266        // Produce a NARInfo the same way nix does.
267        // FUTUREWORK: add tests for NARInfo with unsupported formats
268        // (again referring with compression for example)
269        let narinfo_str = gen_nix_like_narinfo(&PATH_INFO_SYMLINK);
270
271        server
272            .put(&format!(
273                "{}.narinfo",
274                nixbase32::encode(&PATH_INFO_SYMLINK.nar_sha256)
275            ))
276            .text(narinfo_str)
277            .content_type(nix_compat::nix_http::MIME_TYPE_NARINFO)
278            .expect_failure()
279            .await;
280    }
281
282    // Upload a NAR, then a PathInfo referring to that upload.
283    #[traced_test]
284    #[tokio::test]
285    async fn test_upload_nar_then_narinfo() {
286        let (server, _blob_service, _directory_service, _path_info_service) =
287            gen_server(crate::gen_router(100));
288
289        // upload NAR
290        server
291            .put(&format!(
292                "/nar/{}.nar",
293                nixbase32::encode(&PATH_INFO_SYMLINK.nar_sha256)
294            ))
295            .bytes(NAR_CONTENTS_SYMLINK[..].into())
296            .expect_success()
297            .await;
298
299        let narinfo_str = gen_nix_like_narinfo(&PATH_INFO_SYMLINK);
300
301        // upload NARInfo
302        server
303            .put(&format!(
304                "/{}.narinfo",
305                nixbase32::encode(PATH_INFO_SYMLINK.store_path.digest())
306            ))
307            .text(narinfo_str)
308            .content_type(nix_compat::nix_http::MIME_TYPE_NARINFO)
309            .expect_success()
310            .await;
311    }
312}