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_engine,
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_engine, 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_engine,
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) =
158 ingest_nar_and_hash(blob_engine, directory_service.clone(), &mut r, &None)
159 .await
160 .map_err(io::Error::other)
161 .map_err(|e| {
162 warn!(err=%e, "failed to ingest nar");
163 StatusCode::INTERNAL_SERVER_ERROR
164 })?;
165
166 let s = Span::current();
167 s.record("nar_hash.expected", nixbase32::encode(&nar_hash_expected));
168 s.record("nar_size", nar_size);
169
170 if nar_hash_expected != nar_hash_actual {
171 warn!(
172 nar_hash.expected = nixbase32::encode(&nar_hash_expected),
173 nar_hash.actual = nixbase32::encode(&nar_hash_actual),
174 "nar hash mismatch"
175 );
176 return Err(StatusCode::BAD_REQUEST);
177 }
178
179 root_nodes.write().put(nar_hash_actual, root_node);
182
183 Ok("")
184}
185
186#[cfg(test)]
187mod tests {
188 use std::{
189 num::NonZero,
190 sync::{Arc, LazyLock},
191 };
192
193 use axum::{Router, http::Method};
194 use bytes::Bytes;
195 use data_encoding::BASE64URL_NOPAD;
196 use nix_compat::{nixbase32, nixhash::Sha256};
197 use snix_castore::{
198 blob_engine::BlobEngine,
199 directoryservice::DirectoryService,
200 fixtures::HELLOWORLD_BLOB_DIGEST,
201 utils::{gen_test_blob_engine, gen_test_directory_service},
202 };
203 use snix_store::{
204 fixtures::{
205 CASTORE_NODE_COMPLICATED, CASTORE_NODE_SYMLINK, NAR_CONTENTS_COMPLICATED,
206 NAR_CONTENTS_HELLOWORLD, NAR_CONTENTS_SYMLINK,
207 },
208 pathinfoservice::PathInfoService,
209 utils::gen_test_pathinfo_service,
210 };
211 use tracing_test::traced_test;
212
213 use crate::AppState;
214
215 pub static NAR_STR_SYMLINK: LazyLock<String> = LazyLock::new(|| {
216 use prost::Message;
217 BASE64URL_NOPAD.encode(
218 &snix_castore::proto::Entry::from_name_and_node(
219 "".into(),
220 CASTORE_NODE_SYMLINK.clone(),
221 )
222 .encode_to_vec(),
223 )
224 });
225
226 fn gen_server(
229 router: axum::Router<AppState>,
230 ) -> (
231 axum_test::TestServer,
232 impl BlobEngine,
233 impl DirectoryService,
234 impl PathInfoService,
235 ) {
236 let blob_engine = Arc::new(gen_test_blob_engine());
237 let directory_service = Arc::new(gen_test_directory_service());
238 let path_info_service = Arc::new(gen_test_pathinfo_service());
239
240 let app = router.with_state(AppState::new(
241 blob_engine.clone(),
242 directory_service.clone(),
243 path_info_service.clone(),
244 NonZero::new(100).unwrap(),
245 ));
246
247 (
248 axum_test::TestServer::new(app),
249 blob_engine,
250 directory_service,
251 path_info_service,
252 )
253 }
254
255 #[traced_test]
256 #[tokio::test]
257 async fn test_get_head() {
258 let (server, _blob_engine, _directory_service, _path_info_service) =
259 gen_server(Router::new().route(
260 "/nar/snix-castore/{root_node_enc}",
261 axum::routing::get(super::get_head),
262 ));
263
264 server
266 .method(Method::HEAD, "/nar/snix-castore/")
267 .expect_failure()
268 .await
269 .assert_status_not_found();
270
271 let valid_url = &format!("/nar/snix-castore/{}", *NAR_STR_SYMLINK);
272 let qps = &[("narsize", &NAR_CONTENTS_SYMLINK.len().to_string())];
273
274 server
276 .method(Method::HEAD, valid_url)
277 .expect_failure()
278 .await
279 .assert_status_bad_request();
280
281 let invalid_url = {
282 use prost::Message;
283 let n = snix_castore::proto::Entry {
284 entry: Some(snix_castore::proto::entry::Entry::Directory(
285 snix_castore::proto::DirectoryEntry {
286 name: "".into(),
287 digest: "invalid b64".into(),
288 size: 1,
289 },
290 )),
291 };
292 &format!(
293 "/nar/snix-castore/{}",
294 BASE64URL_NOPAD.encode(&n.encode_to_vec())
295 )
296 };
297
298 server
300 .method(Method::HEAD, invalid_url)
301 .add_query_params(qps)
302 .expect_failure()
303 .await
304 .assert_status_not_found();
305
306 server
308 .method(Method::HEAD, valid_url)
309 .add_query_params(qps)
310 .expect_success()
311 .await;
312
313 assert_eq!(
315 NAR_CONTENTS_SYMLINK.as_slice(),
316 server
317 .get(valid_url)
318 .add_query_params(qps)
319 .expect_success()
320 .await
321 .into_bytes(),
322 "Expected to get back NAR_CONTENTS_SYMLINK"
323 )
324 }
325
326 #[traced_test]
329 #[tokio::test]
330 async fn test_put_wrong_narhash() {
331 let (server, _blob_engine, _directory_service, _path_info_service) =
332 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
333
334 server
335 .put("/nar/0000000000000000000000000000000000000000000000000000.nar")
336 .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
337 .expect_failure()
338 .await;
339 }
340
341 #[traced_test]
343 #[tokio::test]
344 async fn test_put_with_compression_fail() {
345 let (server, _blob_engine, _directory_service, _path_info_service) =
346 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
347
348 let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_SYMLINK.as_slice());
349
350 let nar_url = format!("/nar/{}.nar.zst", nixbase32::encode(&nar_sha256));
351
352 server
353 .put(&nar_url)
354 .bytes(Bytes::from_static(&NAR_CONTENTS_SYMLINK))
355 .expect_failure()
356 .await
357 .assert_status_unauthorized();
358 }
359
360 #[traced_test]
362 #[tokio::test]
363 async fn test_put_success() {
364 let (server, blob_engine, _directory_service, _path_info_service) =
365 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
366
367 let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_HELLOWORLD.as_slice());
368 let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
369
370 server
371 .put(&nar_url)
372 .bytes(Bytes::from_static(&NAR_CONTENTS_HELLOWORLD))
373 .expect_success()
374 .await;
375
376 assert!(
377 blob_engine
378 .has(&HELLOWORLD_BLOB_DIGEST)
379 .await
380 .expect("blobengine to have blob")
381 )
382 }
383
384 #[traced_test]
388 #[tokio::test]
389 async fn test_put_success2() {
390 let (server, blob_engine, directory_service, _path_info_service) =
391 gen_server(Router::new().route("/nar/{nar_str}", axum::routing::put(super::put)));
392
393 let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_COMPLICATED.as_slice());
394 let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
395
396 server
397 .put(&nar_url)
398 .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
399 .expect_success()
400 .await;
401
402 let mut buf = Vec::new();
403 snix_store::nar::write_nar(
404 &mut buf,
405 &CASTORE_NODE_COMPLICATED,
406 &blob_engine,
407 &directory_service,
408 )
409 .await
410 .expect("write nar");
411
412 assert_eq!(NAR_CONTENTS_COMPLICATED, buf[..]);
413 }
414
415 #[traced_test]
417 #[tokio::test]
418 async fn test_put_root_nodes() {
419 let (server, _blob_engine, _directory_servicee, _path_info_service) = gen_server(
420 Router::new()
421 .route("/nar/{nar_str}", axum::routing::put(super::put))
422 .route("/nar/{nar_str}", axum::routing::get(super::head_root_nodes)),
423 );
424
425 let nar_sha256 = Sha256::digest_bytes(NAR_CONTENTS_COMPLICATED.as_slice());
426 let nar_url = format!("/nar/{}.nar", nixbase32::encode(&nar_sha256));
427
428 server
430 .put(&nar_url)
431 .bytes(Bytes::from_static(&NAR_CONTENTS_COMPLICATED))
432 .expect_success()
433 .await;
434
435 server.method(Method::HEAD, &nar_url).expect_success().await;
437 }
438}