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