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