1use axum::extract::Query;
2use axum::http::{Response, StatusCode};
3use axum::{body::Body, response::IntoResponse};
4use axum_extra::{TypedHeader, headers::Range};
5use axum_range::{KnownSize, Ranged};
6use bstr::ByteSlice;
7use futures::TryStreamExt;
8use nix_compat::{nix_http, nixbase32};
9use serde::Deserialize;
10use snix_castore::proto::parse_urlsafe_proto;
11use snix_store::nar::ingest_nar_and_hash;
12use std::io;
13use tokio_util::io::ReaderStream;
14use tracing::{Span, instrument, warn};
15
16use crate::AppState;
17
18#[derive(Debug, Deserialize)]
19pub(crate) struct GetNARParams {
20 #[serde(rename = "narsize")]
21 nar_size: Option<u64>,
22}
23
24#[instrument(skip_all)]
25pub async fn get_head(
26 method: axum::http::Method,
27 ranges: Option<TypedHeader<Range>>,
28 axum::extract::Path(root_node_enc): axum::extract::Path<String>,
29 axum::extract::Query(GetNARParams { nar_size }): Query<GetNARParams>,
30 axum::extract::State(AppState {
31 blob_service,
32 directory_service,
33 ..
34 }): axum::extract::State<AppState>,
35) -> Result<impl axum::response::IntoResponse, StatusCode> {
36 let nar_size = nar_size.ok_or_else(|| {
39 warn!("no nar_size parameter set");
40 StatusCode::BAD_REQUEST
41 })?;
42
43 let root_node = parse_urlsafe_proto(root_node_enc).ok_or_else(|| {
45 warn!("unable to parse castore-infused NAR path");
46 StatusCode::NOT_FOUND
47 })?;
48
49 Ok((
50 [
52 ("cache-control", "max-age=31536000, immutable"),
53 ("content-type", nix_http::MIME_TYPE_NAR),
54 ],
55 if method == axum::http::Method::HEAD {
56 Response::builder()
61 .header("content-length", nar_size)
62 .body(Body::empty())
63 .unwrap()
64 } else if let Some(TypedHeader(ranges)) = ranges {
65 let r =
67 snix_store::nar::seekable::Reader::new(root_node, blob_service, directory_service)
68 .await
69 .map_err(|e| {
70 warn!(err=%e, "failed to construct seekable nar reader");
71 StatusCode::INTERNAL_SERVER_ERROR
72 })?;
73
74 if r.stream_len() != nar_size {
76 warn!(
77 actual_nar_size = r.stream_len(),
78 supplied_nar_size = nar_size,
79 "wrong nar size supplied"
80 );
81 return Err(StatusCode::BAD_REQUEST);
82 }
83 Ranged::new(Some(ranges), KnownSize::sized(r, nar_size)).into_response()
84 } else {
85 let (w, r) = tokio::io::duplex(1024 * 8);
88
89 tokio::spawn(async move {
91 if let Err(e) =
92 snix_store::nar::write_nar(w, &root_node, blob_service, directory_service).await
93 {
94 warn!(err=%e, "failed to write out NAR");
95 }
96 });
97
98 Response::builder()
99 .header("content-length", nar_size)
102 .body(Body::from_stream(ReaderStream::new(r)))
103 .unwrap()
104 },
105 ))
106}
107
108#[instrument(skip_all, fields(nar_str))]
116pub async fn head_root_nodes(
117 axum::extract::Path(nar_str): axum::extract::Path<String>,
118 axum::extract::State(AppState { root_nodes, .. }): axum::extract::State<AppState>,
119) -> Result<impl axum::response::IntoResponse, StatusCode> {
120 let (nar_hash, compression_suffix) =
121 nix_http::parse_nar_str(&nar_str).ok_or(StatusCode::UNAUTHORIZED)?;
122
123 if !compression_suffix.is_empty() {
125 let compression_suffix = compression_suffix.as_bstr();
126 warn!(%compression_suffix, "invalid compression suffix");
127 return Err(StatusCode::UNAUTHORIZED);
128 }
129
130 if root_nodes.write().get(&nar_hash).is_some() {
133 Ok("")
134 } else {
135 Err(StatusCode::NOT_FOUND)
136 }
137}
138
139#[instrument(skip_all)]
140pub async fn put(
141 axum::extract::Path(nar_str): axum::extract::Path<String>,
142 axum::extract::State(AppState {
143 blob_service,
144 directory_service,
145 root_nodes,
146 ..
147 }): axum::extract::State<AppState>,
148 request: axum::extract::Request,
149) -> Result<&'static str, StatusCode> {
150 let (nar_hash_expected, compression_suffix) =
151 nix_http::parse_nar_str(&nar_str).ok_or(StatusCode::UNAUTHORIZED)?;
152
153 if !compression_suffix.is_empty() {
155 let compression_suffix = compression_suffix.as_bstr();
156 warn!(%compression_suffix, "invalid compression suffix");
157 return Err(StatusCode::UNAUTHORIZED);
158 }
159
160 let s = request.into_body().into_data_stream();
161
162 let mut r = tokio_util::io::StreamReader::new(s.map_err(|e| {
163 warn!(err=%e, "failed to read request body");
164 io::Error::new(io::ErrorKind::BrokenPipe, e.to_string())
165 }));
166
167 let (root_node, nar_hash_actual, nar_size) = ingest_nar_and_hash(
169 blob_service.clone(),
170 directory_service.clone(),
171 &mut r,
172 &None,
173 )
174 .await
175 .map_err(io::Error::other)
176 .map_err(|e| {
177 warn!(err=%e, "failed to ingest nar");
178 StatusCode::INTERNAL_SERVER_ERROR
179 })?;
180
181 let s = Span::current();
182 s.record("nar_hash.expected", nixbase32::encode(&nar_hash_expected));
183 s.record("nar_size", nar_size);
184
185 if nar_hash_expected != nar_hash_actual {
186 warn!(
187 nar_hash.expected = nixbase32::encode(&nar_hash_expected),
188 nar_hash.actual = nixbase32::encode(&nar_hash_actual),
189 "nar hash mismatch"
190 );
191 return Err(StatusCode::BAD_REQUEST);
192 }
193
194 root_nodes.write().put(nar_hash_actual, root_node);
197
198 Ok("")
199}
200
201#[cfg(test)]
202mod tests {
203 use std::{
204 num::NonZero,
205 sync::{Arc, LazyLock},
206 };
207
208 use axum::{Router, http::Method};
209 use bytes::Bytes;
210 use data_encoding::BASE64URL_NOPAD;
211 use nix_compat::nixbase32;
212 use sha2::Digest;
213 use snix_castore::{
214 blobservice::{BlobService, MemoryBlobService},
215 directoryservice::DirectoryService,
216 fixtures::HELLOWORLD_BLOB_DIGEST,
217 utils::gen_test_directory_service,
218 };
219 use snix_store::{
220 fixtures::{
221 CASTORE_NODE_COMPLICATED, CASTORE_NODE_SYMLINK, NAR_CONTENTS_COMPLICATED,
222 NAR_CONTENTS_HELLOWORLD, NAR_CONTENTS_SYMLINK,
223 },
224 pathinfoservice::PathInfoService,
225 utils::gen_test_pathinfo_service,
226 };
227 use tracing_test::traced_test;
228
229 use crate::AppState;
230
231 pub static NAR_STR_SYMLINK: LazyLock<String> = LazyLock::new(|| {
232 use prost::Message;
233 BASE64URL_NOPAD.encode(
234 &snix_castore::proto::Entry::from_name_and_node(
235 "".into(),
236 CASTORE_NODE_SYMLINK.clone(),
237 )
238 .encode_to_vec(),
239 )
240 });
241
242 fn gen_server(
245 router: axum::Router<AppState>,
246 ) -> (
247 axum_test::TestServer,
248 impl BlobService,
249 impl DirectoryService,
250 impl PathInfoService,
251 ) {
252 let blob_service = Arc::new(MemoryBlobService::default());
253 let directory_service = Arc::new(gen_test_directory_service());
254 let path_info_service = Arc::new(gen_test_pathinfo_service());
255
256 let app = router.with_state(AppState::new(
257 blob_service.clone(),
258 directory_service.clone(),
259 path_info_service.clone(),
260 NonZero::new(100).unwrap(),
261 ));
262
263 (
264 axum_test::TestServer::new(app),
265 blob_service,
266 directory_service,
267 path_info_service,
268 )
269 }
270
271 #[traced_test]
272 #[tokio::test]
273 async fn test_get_head() {
274 let (server, _blob_service, _directory_service, _path_info_service) =
275 gen_server(Router::new().route(
276 "/nar/snix-castore/{root_node_enc}",
277 axum::routing::get(super::get_head),
278 ));
279
280 server
282 .method(Method::HEAD, "/nar/snix-castore/")
283 .expect_failure()
284 .await
285 .assert_status_not_found();
286
287 let valid_url = &format!("/nar/snix-castore/{}", &*NAR_STR_SYMLINK);
288 let qps = &[("narsize", &NAR_CONTENTS_SYMLINK.len().to_string())];
289
290 server
292 .method(Method::HEAD, valid_url)
293 .expect_failure()
294 .await
295 .assert_status_bad_request();
296
297 let invalid_url = {
298 use prost::Message;
299 let n = snix_castore::proto::Entry {
300 entry: Some(snix_castore::proto::entry::Entry::Directory(
301 snix_castore::proto::DirectoryEntry {
302 name: "".into(),
303 digest: "invalid b64".into(),
304 size: 1,
305 },
306 )),
307 };
308 &format!(
309 "/nar/snix-castore/{}",
310 BASE64URL_NOPAD.encode(&n.encode_to_vec())
311 )
312 };
313
314 server
316 .method(Method::HEAD, invalid_url)
317 .add_query_params(qps)
318 .expect_failure()
319 .await
320 .assert_status_not_found();
321
322 server
324 .method(Method::HEAD, valid_url)
325 .add_query_params(qps)
326 .expect_success()
327 .await;
328
329 assert_eq!(
331 NAR_CONTENTS_SYMLINK.as_slice(),
332 server
333 .get(valid_url)
334 .add_query_params(qps)
335 .expect_success()
336 .await
337 .into_bytes(),
338 "Expected to get back NAR_CONTENTS_SYMLINK"
339 )
340 }
341
342 #[traced_test]
345 #[tokio::test]
346 async fn test_put_wrong_narhash() {
347 let (server, _blob_service, _directory_service, _path_info_service) =
348 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
349
350 server
351 .put("/nar/0000000000000000000000000000000000000000000000000000.nar")
352 .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
353 .expect_failure()
354 .await;
355 }
356
357 #[traced_test]
359 #[tokio::test]
360 async fn test_put_with_compression_fail() {
361 let (server, _blob_service, _directory_service, _path_info_service) =
362 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
363
364 let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_SYMLINK.as_slice()).into();
365
366 let nar_url = format!("/nar/{}.nar.zst", nixbase32::encode(&nar_sha256));
367
368 server
369 .put(&nar_url)
370 .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
371 .expect_failure()
372 .await
373 .assert_status_unauthorized();
374 }
375
376 #[traced_test]
378 #[tokio::test]
379 async fn test_put_success() {
380 let (server, blob_service, _directory_service, _path_info_service) =
381 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
382
383 let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_HELLOWORLD.as_slice()).into();
384 let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
385
386 server
387 .put(&nar_url)
388 .bytes(Bytes::from_static(&NAR_CONTENTS_HELLOWORLD))
389 .expect_success()
390 .await;
391
392 assert!(
393 blob_service
394 .has(&HELLOWORLD_BLOB_DIGEST)
395 .await
396 .expect("blobservice")
397 )
398 }
399
400 #[traced_test]
404 #[tokio::test]
405 async fn test_put_success2() {
406 let (server, blob_service, directory_service, _path_info_service) =
407 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
408
409 let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_COMPLICATED.as_slice()).into();
410 let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
411
412 server
413 .put(&nar_url)
414 .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
415 .expect_success()
416 .await;
417
418 let mut buf = Vec::new();
419 snix_store::nar::write_nar(
420 &mut buf,
421 &CASTORE_NODE_COMPLICATED,
422 blob_service,
423 directory_service,
424 )
425 .await
426 .expect("write nar");
427
428 assert_eq!(NAR_CONTENTS_COMPLICATED, buf[..]);
429 }
430
431 #[traced_test]
433 #[tokio::test]
434 async fn test_put_root_nodes() {
435 let (server, _blob_service, _directory_servicee, _path_info_service) = gen_server(
436 Router::new()
437 .route("/nar/{nar_str}", axum::routing::put(super::put))
438 .route("/nar/{nar_str}", axum::routing::get(super::head_root_nodes)),
439 );
440
441 let nar_sha256: [u8; 32] = sha2::Sha256::digest(NAR_CONTENTS_COMPLICATED.as_slice()).into();
442 let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
443
444 server
446 .put(&nar_url)
447 .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
448 .expect_success()
449 .await;
450
451 server.method(Method::HEAD, &nar_url).expect_success().await;
453 }
454}