1use nix_compat::{
2 nar::reader::r#async as nar_reader,
3 nixhash::{CAHash, NixHash, NixHashDigester, Sha256Digester, copy_hashed},
4};
5use snix_castore::{
6 Node, PathBuf,
7 blobservice::BlobService,
8 directoryservice::DirectoryService,
9 import::{
10 IngestionEntry, IngestionError,
11 blobs::{self, ConcurrentBlobUploader},
12 ingest_entries,
13 },
14};
15use tokio::{
16 io::{AsyncBufRead, AsyncRead},
17 sync::mpsc,
18 try_join,
19};
20use tokio_util::io::InspectReader;
21
22#[derive(Debug, thiserror::Error)]
24pub enum NarIngestionError {
25 #[error("{0}")]
26 IngestionError(#[from] IngestionError<Error>),
27
28 #[error("Hash mismatch, expected: {expected}, got: {actual}.")]
29 HashMismatch { expected: NixHash, actual: NixHash },
30
31 #[error("Expected the nar to contain a single file.")]
32 TypeMismatch,
33
34 #[error("Ingestion failed: {0}")]
35 Io(#[from] std::io::Error),
36}
37
38pub async fn ingest_nar_and_hash<R, BS, DS>(
43 blob_service: BS,
44 directory_service: DS,
45 r: &mut R,
46 expected_cahash: &Option<CAHash>,
47) -> Result<(Node, [u8; 32], u64), NarIngestionError>
48where
49 R: AsyncRead + Unpin + Send,
50 BS: BlobService + Clone + 'static,
51 DS: DirectoryService,
52{
53 let mut nar_hash = Sha256Digester::new();
54 let mut nar_size = 0;
55
56 let mut r = tokio_util::io::InspectReader::new(r, |b| {
58 nar_size += b.len() as u64;
59 nar_hash.update(b);
60 });
61
62 match expected_cahash {
63 Some(CAHash::Nar(expected_hash)) => {
64 let mut digester = NixHashDigester::new(expected_hash.algo());
68 let mut ca_reader = InspectReader::new(r, |data| digester.update(data));
69 let mut r = tokio::io::BufReader::new(&mut ca_reader);
70 let root_node = ingest_nar(blob_service, directory_service, &mut r).await?;
71 let actual_hash = digester.finalize();
72
73 if actual_hash != *expected_hash {
74 return Err(NarIngestionError::HashMismatch {
75 expected: expected_hash.clone(),
76 actual: actual_hash,
77 });
78 }
79 Ok((root_node, nar_hash.finalize().into(), nar_size))
80 }
81 Some(CAHash::Flat(expected_hash)) => {
82 let mut r = tokio::io::BufReader::new(&mut r);
83 let root_node = ingest_nar(blob_service.clone(), directory_service, &mut r).await?;
84 match &root_node {
85 Node::File { digest, .. } => match blob_service.open_read(digest).await? {
86 Some(mut blob_reader) => {
87 let (_, actual_hash) = copy_hashed(
88 &mut blob_reader,
89 &mut tokio::io::sink(),
90 expected_hash.algo(),
91 )
92 .await?;
93
94 if actual_hash != *expected_hash {
95 return Err(NarIngestionError::HashMismatch {
96 expected: expected_hash.clone(),
97 actual: actual_hash,
98 });
99 }
100 Ok((root_node, nar_hash.finalize().into(), nar_size))
101 }
102 None => Err(NarIngestionError::Io(std::io::Error::other(
103 "Ingested data not found",
104 ))),
105 },
106 _ => Err(NarIngestionError::TypeMismatch),
107 }
108 }
109 _ => {
115 let mut r = tokio::io::BufReader::new(&mut r);
116 let root_node = ingest_nar(blob_service, directory_service, &mut r).await?;
117 Ok((root_node, nar_hash.finalize().into(), nar_size))
118 }
119 }
120}
121
122pub async fn ingest_nar<R, BS, DS>(
126 blob_service: BS,
127 directory_service: DS,
128 r: &mut R,
129) -> Result<Node, IngestionError<Error>>
130where
131 R: AsyncBufRead + Unpin + Send,
132 BS: BlobService + Clone + 'static,
133 DS: DirectoryService,
134{
135 let root_node = nar_reader::open(r).await.map_err(Error::IO)?;
138
139 let (tx, rx) = mpsc::channel(1);
140 let rx = tokio_stream::wrappers::ReceiverStream::new(rx);
141
142 let produce = async move {
143 let mut blob_uploader = ConcurrentBlobUploader::new(blob_service);
144
145 let res = produce_nar_inner(
146 &mut blob_uploader,
147 root_node,
148 "root".parse().unwrap(), tx.clone(),
150 )
151 .await;
152
153 if let Err(err) = blob_uploader.join().await {
154 tx.send(Err(err.into()))
155 .await
156 .map_err(|e| Error::IO(std::io::Error::new(std::io::ErrorKind::BrokenPipe, e)))?;
157 }
158
159 tx.send(res)
160 .await
161 .map_err(|e| Error::IO(std::io::Error::new(std::io::ErrorKind::BrokenPipe, e)))?;
162
163 Ok(())
164 };
165
166 let consume = ingest_entries(directory_service, rx);
167
168 let (_, node) = try_join!(produce, consume)?;
169
170 Ok(node)
171}
172
173async fn produce_nar_inner<BS>(
174 blob_uploader: &mut ConcurrentBlobUploader<BS>,
175 node: nar_reader::Node<'_, '_>,
176 path: PathBuf,
177 tx: mpsc::Sender<Result<IngestionEntry, Error>>,
178) -> Result<IngestionEntry, Error>
179where
180 BS: BlobService + Clone + 'static,
181{
182 Ok(match node {
183 nar_reader::Node::Symlink { target } => IngestionEntry::Symlink { path, target },
184 nar_reader::Node::File {
185 executable,
186 mut reader,
187 } => {
188 let size = reader.len();
189 let digest = blob_uploader.upload(&path, size, &mut reader).await?;
190
191 IngestionEntry::Regular {
192 path,
193 size,
194 executable,
195 digest,
196 }
197 }
198 nar_reader::Node::Directory(mut dir_reader) => {
199 while let Some(entry) = dir_reader.next().await? {
200 let mut path = path.clone();
201
202 path.try_push(entry.name)
204 .expect("Snix bug: failed to join name");
205
206 let entry = Box::pin(produce_nar_inner(
207 blob_uploader,
208 entry.node,
209 path,
210 tx.clone(),
211 ))
212 .await?;
213
214 tx.send(Ok(entry)).await.map_err(|e| {
215 Error::IO(std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))
216 })?;
217 }
218
219 IngestionEntry::Dir { path }
220 }
221 })
222}
223
224#[derive(Debug, thiserror::Error)]
225pub enum Error {
226 #[error(transparent)]
227 IO(#[from] std::io::Error),
228
229 #[error(transparent)]
230 BlobUpload(#[from] blobs::Error),
231}
232
233#[cfg(test)]
234mod test {
235 use crate::fixtures::{
236 NAR_CONTENTS_COMPLICATED, NAR_CONTENTS_HELLOWORLD, NAR_CONTENTS_SYMLINK,
237 };
238 use crate::nar::{NarIngestionError, ingest_nar, ingest_nar_and_hash};
239 use std::io::Cursor;
240 use std::sync::Arc;
241
242 use hex_literal::hex;
243 use mockall::predicate;
244 use nix_compat::nixhash::{CAHash, NixHash};
245 use rstest::*;
246 use snix_castore::Node;
247 use snix_castore::blobservice::{MockBlobService, TestBlobWriter};
248 use snix_castore::directoryservice::{MockDirectoryPutter, MockDirectoryService};
249 use snix_castore::fixtures::{
250 DIRECTORY_COMPLICATED, DIRECTORY_WITH_KEEP, EMPTY_BLOB_DIGEST, HELLOWORLD_BLOB_CONTENTS,
251 HELLOWORLD_BLOB_DIGEST,
252 };
253 use snix_castore::utils::gen_test_blob_service;
254
255 #[tokio::test]
256 async fn single_symlink() {
257 let root_node = ingest_nar(
258 Arc::new(MockBlobService::new()),
259 MockDirectoryService::new(),
260 &mut Cursor::new(&NAR_CONTENTS_SYMLINK),
261 )
262 .await
263 .expect("must parse");
264
265 assert_eq!(
266 Node::Symlink {
267 target: "/nix/store/somewhereelse".try_into().unwrap()
268 },
269 root_node
270 );
271 }
272
273 #[tokio::test]
274 async fn single_file() {
275 let mut blob_service = MockBlobService::new();
276 let mut seq = mockall::Sequence::new();
277 blob_service
278 .expect_has()
279 .once()
280 .with(predicate::eq(&*HELLOWORLD_BLOB_DIGEST))
281 .return_once(|_| Ok(false))
282 .in_sequence(&mut seq);
283
284 blob_service
285 .expect_open_write()
286 .once()
287 .return_once(|| Box::new(TestBlobWriter::new()))
288 .in_sequence(&mut seq);
289
290 let root_node = ingest_nar(
291 Arc::new(blob_service),
292 MockDirectoryService::new(),
293 &mut Cursor::new(&NAR_CONTENTS_HELLOWORLD),
294 )
295 .await
296 .expect("must parse");
297
298 assert_eq!(
299 Node::File {
300 digest: *HELLOWORLD_BLOB_DIGEST,
301 size: HELLOWORLD_BLOB_CONTENTS.len() as u64,
302 executable: false,
303 },
304 root_node
305 );
306 }
307
308 #[tokio::test]
309 async fn complicated() {
310 let mut blob_service = MockBlobService::new();
311 let mut seq = mockall::Sequence::new();
312 blob_service
313 .expect_has()
314 .once()
315 .with(predicate::eq(&*EMPTY_BLOB_DIGEST))
316 .return_once(|_| Ok(false))
317 .in_sequence(&mut seq);
318 blob_service
319 .expect_open_write()
320 .once()
321 .return_once(|| Box::new(TestBlobWriter::new()))
322 .in_sequence(&mut seq);
323 blob_service
324 .expect_has()
325 .once()
326 .with(predicate::eq(&*EMPTY_BLOB_DIGEST))
327 .return_once(|_| Ok(true))
328 .in_sequence(&mut seq);
329 let mut directory_service = MockDirectoryService::new();
330 directory_service
331 .expect_put_multiple_start()
332 .once()
333 .return_once(|| {
334 let mut directory_putter = MockDirectoryPutter::new();
335 let mut seq = mockall::Sequence::new();
336 directory_putter
337 .expect_put()
338 .once()
339 .with(predicate::eq(&*DIRECTORY_WITH_KEEP))
340 .returning(|_| Ok(()))
341 .in_sequence(&mut seq);
342 directory_putter
343 .expect_put()
344 .once()
345 .with(predicate::eq(&*DIRECTORY_COMPLICATED))
346 .returning(|_| Ok(()))
347 .in_sequence(&mut seq);
348 directory_putter
349 .expect_close()
350 .once()
351 .returning(|| Ok(DIRECTORY_COMPLICATED.digest()))
352 .in_sequence(&mut seq);
353 Box::new(directory_putter)
354 });
355
356 let root_node = ingest_nar(
357 Arc::new(blob_service),
358 directory_service,
359 &mut Cursor::new(&NAR_CONTENTS_COMPLICATED),
360 )
361 .await
362 .expect("must parse");
363
364 assert_eq!(
365 Node::Directory {
366 digest: DIRECTORY_COMPLICATED.digest(),
367 size: DIRECTORY_COMPLICATED.size()
368 },
369 root_node,
370 );
371 }
372
373 #[rstest]
374 #[case::nar_sha256(Some(CAHash::Nar(NixHash::Sha256(hex!("fbd52279a8df024c9fd5718de4103bf5e760dc7f2cf49044ee7dea87ab16911a")))), NAR_CONTENTS_COMPLICATED.as_slice())]
375 #[case::nar_sha512(Some(CAHash::Nar(NixHash::Sha512(Box::new(hex!("ff5d43941411f35f09211f8596b426ee6e4dd3af1639e0ed2273cbe44b818fc4a59e3af02a057c5b18fbfcf435497de5f1994206c137f469b3df674966a922f0"))))), NAR_CONTENTS_COMPLICATED.as_slice())]
376 #[case::flat_md5(Some(CAHash::Flat(NixHash::Md5(hex!("fd076287532e86365e841e92bfc50d8c")))), NAR_CONTENTS_HELLOWORLD.as_slice() )]
377 #[case::nar_symlink_sha1(Some(CAHash::Nar(NixHash::Sha1(hex!("f24eeaaa9cc016bab030bf007cb1be6483e7ba9e")))), NAR_CONTENTS_SYMLINK.as_slice())]
378 #[tokio::test]
379 async fn ingest_with_cahash_mismatch(
380 #[case] ca_hash: Option<CAHash>,
381 #[case] nar_content: &[u8],
382 ) {
383 use snix_castore::utils::gen_test_directory_service;
384
385 let err = ingest_nar_and_hash(
386 gen_test_blob_service(),
387 gen_test_directory_service(),
388 &mut Cursor::new(nar_content),
389 &ca_hash,
390 )
391 .await
392 .expect_err("Ingestion should have failed");
393 assert!(
394 matches!(err, NarIngestionError::HashMismatch { .. }),
395 "CAHash should have mismatched"
396 );
397 }
398
399 #[rstest]
400 #[case::nar_sha256(Some(CAHash::Nar(NixHash::Sha256(hex!("ebd52279a8df024c9fd5718de4103bf5e760dc7f2cf49044ee7dea87ab16911a")))), &NAR_CONTENTS_COMPLICATED.clone())]
401 #[case::nar_sha512(Some(CAHash::Nar(NixHash::Sha512(Box::new(hex!("1f5d43941411f35f09211f8596b426ee6e4dd3af1639e0ed2273cbe44b818fc4a59e3af02a057c5b18fbfcf435497de5f1994206c137f469b3df674966a922f0"))))), &NAR_CONTENTS_COMPLICATED.clone())]
402 #[case::flat_md5(Some(CAHash::Flat(NixHash::Md5(hex!("ed076287532e86365e841e92bfc50d8c")))), &NAR_CONTENTS_HELLOWORLD.clone())]
403 #[case::nar_symlink_sha1(Some(CAHash::Nar(NixHash::Sha1(hex!("424eeaaa9cc016bab030bf007cb1be6483e7ba9e")))), &NAR_CONTENTS_SYMLINK.clone())]
404 #[tokio::test]
405 async fn ingest_with_cahash_correct(
406 #[case] ca_hash: Option<CAHash>,
407 #[case] nar_content: &[u8],
408 ) {
409 ingest_nar_and_hash(
410 snix_castore::utils::gen_test_blob_service(),
411 snix_castore::utils::gen_test_directory_service(),
412 &mut Cursor::new(nar_content),
413 &ca_hash,
414 )
415 .await
416 .expect("CAHash should have matched");
417 }
418
419 #[rstest]
420 #[case::nar_sha256(Some(CAHash::Flat(NixHash::Sha256(hex!("ebd52279a8df024c9fd5718de4103bf5e760dc7f2cf49044ee7dea87ab16911a")))), &NAR_CONTENTS_COMPLICATED.clone())]
421 #[case::nar_symlink_sha1(Some(CAHash::Flat(NixHash::Sha1(hex!("424eeaaa9cc016bab030bf007cb1be6483e7ba9e")))), &NAR_CONTENTS_SYMLINK.clone())]
422 #[tokio::test]
423 async fn ingest_with_flat_non_file(
424 #[case] ca_hash: Option<CAHash>,
425 #[case] nar_content: &[u8],
426 ) {
427 let err = ingest_nar_and_hash(
428 snix_castore::utils::gen_test_blob_service(),
429 snix_castore::utils::gen_test_directory_service(),
430 &mut Cursor::new(nar_content),
431 &ca_hash,
432 )
433 .await
434 .expect_err("Ingestion should have failed");
435
436 assert!(
437 matches!(err, NarIngestionError::TypeMismatch),
438 "Flat cahash should only be allowed for single file nars"
439 );
440 }
441}