1use data_encoding::HEXLOWER;
2use futures::TryStreamExt;
3use nix_compat::{
4 nixhash::{CAHash, CAHashMode, HashAlgo, NixHash, NixHashDigester, copy_buf_hashed},
5 store_path::{ParseStorePathError, StorePathRef, build_ca_path},
6};
7use snix_castore::{Node, blobservice::BlobService, directoryservice::DirectoryService};
8use snix_store::{
9 decompression::DecompressedReader,
10 nar::{NarCalculationService, NarIngestionError},
11 pathinfoservice::{PathInfo, PathInfoService},
12};
13use tokio::io::{AsyncBufRead, AsyncWriteExt, BufReader};
14use tokio_util::io::{InspectReader, InspectWriter};
15use tracing::{Span, instrument, warn};
16use tracing_indicatif::span_ext::IndicatifSpanExt;
17use url::Url;
18
19mod error;
20pub use error::FetcherError;
21
22#[derive(Clone, Eq, PartialEq)]
24pub enum Fetch {
25 URL {
28 url: Url,
30 exp_hash: Option<NixHash>,
32 },
33
34 Tarball {
42 url: Url,
44 exp_nar_sha256: Option<[u8; 32]>,
46 },
47
48 NAR {
51 url: Url,
53 hash: NixHash,
56 },
57
58 Executable {
66 url: Url,
68 hash: NixHash,
71 },
72
73 Git(),
75}
76
77fn redact_url(url: &Url) -> Url {
79 let mut url = url.to_owned();
80 if !url.username().is_empty() {
81 let _ = url.set_username("redacted");
82 }
83
84 if url.password().is_some() {
85 let _ = url.set_password(Some("redacted"));
86 }
87
88 url
89}
90
91impl std::fmt::Debug for Fetch {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 Fetch::URL { url, exp_hash } => {
95 let url = redact_url(url);
96 if let Some(exp_hash) = exp_hash {
97 write!(f, "URL [url: {}, exp_hash: Some({})]", url, exp_hash)
98 } else {
99 write!(f, "URL [url: {}, exp_hash: None]", url)
100 }
101 }
102 Fetch::Tarball {
103 url,
104 exp_nar_sha256,
105 } => {
106 let url = redact_url(url);
107 if let Some(exp_nar_sha256) = exp_nar_sha256 {
108 write!(
109 f,
110 "Tarball [url: {}, exp_nar_sha256: Some({})]",
111 url,
112 NixHash::Sha256(*exp_nar_sha256)
113 )
114 } else {
115 write!(f, "Tarball [url: {url}, exp_hash: None]")
116 }
117 }
118 Fetch::NAR { url, hash } => {
119 let url = redact_url(url);
120 write!(f, "NAR [url: {}, hash: {}]", url, hash)
121 }
122 Fetch::Executable { url, hash } => {
123 let url = redact_url(url);
124 write!(f, "Executable [url: {}, hash: {}]", url, hash)
125 }
126 Fetch::Git() => todo!(),
127 }
128 }
129}
130
131impl Fetch {
132 pub fn store_path<'a>(
136 &self,
137 name: &'a str,
138 ) -> Result<Option<StorePathRef<'a>>, ParseStorePathError> {
139 let ca_hash = match self {
140 Fetch::URL {
141 exp_hash: Some(exp_hash),
142 ..
143 } => CAHash::Flat(exp_hash.clone()),
144
145 Fetch::Tarball {
146 exp_nar_sha256: Some(exp_nar_sha256),
147 ..
148 } => CAHash::Nar(NixHash::Sha256(*exp_nar_sha256)),
149
150 Fetch::NAR { hash, .. } | Fetch::Executable { hash, .. } => {
151 CAHash::Nar(hash.to_owned())
152 }
153
154 Fetch::Git() => unimplemented!(),
155
156 Fetch::URL { exp_hash: None, .. }
158 | Fetch::Tarball {
159 exp_nar_sha256: None,
160 ..
161 } => return Ok(None),
162 };
163
164 build_ca_path(
166 name,
167 ca_hash.mode() == CAHashMode::Nar,
168 &ca_hash.hash(),
169 [],
170 false,
171 )
172 .map(Some)
173 }
174}
175
176pub struct Fetcher<BS, DS, PS, NS> {
178 http_client: reqwest::Client,
179 blob_service: BS,
180 directory_service: DS,
181 path_info_service: PS,
182 nar_calculation_service: NS,
183 hashed_mirrors: Vec<Url>,
184}
185
186impl<BS, DS, PS, NS> Fetcher<BS, DS, PS, NS> {
187 pub fn new(
188 blob_service: BS,
189 directory_service: DS,
190 path_info_service: PS,
191 nar_calculation_service: NS,
192 hashed_mirrors: Vec<Url>,
193 ) -> Self {
194 Self {
195 http_client: reqwest::Client::builder()
196 .user_agent(crate::USER_AGENT)
197 .build()
198 .expect("Client::new()"),
199 blob_service,
200 directory_service,
201 path_info_service,
202 nar_calculation_service,
203 hashed_mirrors,
204 }
205 }
206
207 async fn do_download(
211 &self,
212 url: Url,
213 ) -> Result<Box<dyn AsyncBufRead + Unpin + Send>, FetcherError> {
214 let span = Span::current();
215 match url.scheme() {
216 "file" => {
217 let f = tokio::fs::File::open(url.to_file_path().map_err(|_| {
218 FetcherError::Io(std::io::Error::new(
221 std::io::ErrorKind::InvalidData,
222 "invalid host for file:// scheme",
223 ))
224 })?)
225 .await?;
226
227 span.pb_set_length(f.metadata().await?.len());
228 span.pb_set_style(&snix_tracing::PB_TRANSFER_STYLE);
229 span.pb_start();
230 Ok(Box::new(tokio::io::BufReader::new(InspectReader::new(
231 f,
232 move |d| {
233 span.pb_inc(d.len() as u64);
234 },
235 ))))
236 }
237 _ => {
238 let resp = self.http_client.get(url.clone()).send().await?;
239 if !resp.status().is_success() {
240 use reqwest::StatusCode;
241 use std::io::ErrorKind;
242 let kind = match resp.status() {
243 StatusCode::BAD_REQUEST
244 | StatusCode::NOT_ACCEPTABLE
245 | StatusCode::URI_TOO_LONG => ErrorKind::InvalidData,
246 StatusCode::FORBIDDEN
247 | StatusCode::UNAUTHORIZED
248 | StatusCode::NETWORK_AUTHENTICATION_REQUIRED => {
249 ErrorKind::PermissionDenied
250 }
251 StatusCode::NOT_FOUND | StatusCode::GONE => ErrorKind::NotFound,
252 StatusCode::METHOD_NOT_ALLOWED => ErrorKind::Unsupported,
253 StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => {
254 ErrorKind::TimedOut
255 }
256 StatusCode::TOO_MANY_REQUESTS => ErrorKind::QuotaExceeded,
257 StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE => {
258 ErrorKind::ResourceBusy
259 }
260 _ => ErrorKind::Other,
261 };
262 return Err(FetcherError::Io(std::io::Error::new(
263 kind,
264 format!("unable to download '{}': {}", url, resp.status()),
265 )));
266 }
267
268 if let Some(content_length) = resp.content_length() {
269 span.pb_set_length(content_length);
270 span.pb_set_style(&snix_tracing::PB_TRANSFER_STYLE);
271 } else {
272 span.pb_set_style(&snix_tracing::PB_TRANSFER_STYLE);
273 }
274 span.pb_start();
275
276 Ok(Box::new(tokio_util::io::StreamReader::new(
277 resp.bytes_stream()
278 .inspect_ok(move |d| {
279 span.pb_inc(d.len() as u64);
280 })
281 .map_err(|e| {
282 let e = e.without_url();
283 warn!(%e, "failed to get response body");
284 std::io::Error::new(std::io::ErrorKind::BrokenPipe, e)
285 }),
286 )))
287 }
288 }
289 }
290
291 #[instrument(skip_all, fields(url, indicatif.pb_show=tracing::field::Empty), err)]
298 async fn download(
299 &self,
300 url: Url,
301 exp_hash: Option<&NixHash>,
302 ) -> Result<Box<dyn AsyncBufRead + Unpin + Send>, FetcherError> {
303 let span = Span::current();
304 span.pb_set_message(&format!(
305 "📡Fetching {}",
306 redact_url(&url)
308 ));
309 if let Some(hash) = exp_hash {
310 let urls = self.hashed_mirrors.iter().map(|u| {
311 u.join(&format!(
312 "{}/{}",
313 hash.algo(),
314 HEXLOWER.encode(hash.digest_as_bytes())
315 ))
316 .expect("Snix bug!")
319 });
320 for url in urls {
321 if let Ok(result) = self.do_download(url).await {
322 return Ok(result);
323 }
324 }
325 }
326 self.do_download(url).await
327 }
328}
329
330impl<BS, DS, PS, NS> Fetcher<BS, DS, PS, NS>
331where
332 BS: BlobService + Clone + 'static,
333 DS: DirectoryService + Clone,
334 PS: PathInfoService,
335 NS: NarCalculationService,
336{
337 pub async fn ingest(&self, fetch: Fetch) -> Result<(Node, CAHash, u64), FetcherError> {
342 match fetch {
343 Fetch::URL { url, exp_hash } => {
344 let mut r = self.download(url.clone(), exp_hash.as_ref()).await?;
346
347 let mut blob_writer = self.blob_service.open_write().await;
349
350 let algo = exp_hash
354 .as_ref()
355 .map(NixHash::algo)
356 .unwrap_or_else(|| HashAlgo::Sha256);
357 let (blob_size, actual_hash) =
358 copy_buf_hashed(&mut r, &mut blob_writer, algo).await?;
359
360 if let Some(exp_hash) = exp_hash
361 && exp_hash != actual_hash
362 {
363 return Err(FetcherError::hash_mismatch(url, exp_hash, actual_hash));
364 }
365
366 Ok((
368 Node::File {
369 digest: blob_writer.close().await?,
370 size: blob_size,
371 executable: false,
372 },
373 CAHash::Flat(actual_hash),
374 blob_size,
375 ))
376 }
377 Fetch::Tarball {
378 url,
379 exp_nar_sha256,
380 } => {
381 let r = self.download(url.clone(), None).await?;
384
385 let r = DecompressedReader::new(r).await?;
387
388 let node = snix_castore::import::archive::ingest_archive(
390 self.blob_service.clone(),
391 self.directory_service.clone(),
392 r,
393 )
394 .await?;
395
396 let (nar_size, actual_nar_sha256) = self
401 .nar_calculation_service
402 .calculate_nar(&node)
403 .await
404 .map_err(|e| {
405 FetcherError::Io(std::io::Error::other(e))
407 })?;
408
409 if let Some(exp_nar_sha256) = exp_nar_sha256
410 && exp_nar_sha256 != actual_nar_sha256
411 {
412 return Err(FetcherError::hash_mismatch(
413 url,
414 NixHash::Sha256(exp_nar_sha256),
415 NixHash::Sha256(actual_nar_sha256),
416 ));
417 }
418
419 Ok((
420 node,
421 CAHash::Nar(NixHash::Sha256(actual_nar_sha256)),
422 nar_size,
423 ))
424 }
425 Fetch::NAR {
426 url,
427 hash: exp_hash,
428 } => {
429 let r = self.download(url.clone(), Some(&exp_hash)).await?;
431
432 let mut r = DecompressedReader::new(r).await?;
434
435 let (root_node, _actual_nar_sha256, actual_nar_size) =
437 snix_store::nar::ingest_nar_and_hash(
438 self.blob_service.clone(),
439 self.directory_service.clone(),
440 &mut r,
441 &Some(CAHash::Nar(exp_hash.clone())),
442 )
443 .await
444 .map_err(|e| match e {
445 NarIngestionError::HashMismatch { expected, actual } => {
446 FetcherError::hash_mismatch(url, expected, actual)
447 }
448 _ => FetcherError::Io(std::io::Error::other(e.to_string())),
449 })?;
450 Ok((
451 root_node,
452 CAHash::Nar(exp_hash),
454 actual_nar_size,
455 ))
456 }
457 Fetch::Executable {
458 url,
459 hash: exp_hash,
460 } => {
461 let mut r = self.download(url.clone(), Some(&exp_hash)).await?;
463
464 let mut blob_writer = self.blob_service.open_write().await;
466
467 let file_size = tokio::io::copy(&mut r, &mut blob_writer).await?;
469 let blob_digest = blob_writer.close().await?;
470
471 let w = tokio::io::sink();
477 let mut digester = NixHashDigester::new(exp_hash.algo());
478 let mut nar_size: u64 = 0;
479 let mut w = InspectWriter::new(w, |d| {
480 digester.update(d);
481 nar_size += d.len() as u64;
482 });
483
484 {
485 let node = nix_compat::nar::writer::r#async::open(&mut w).await?;
486
487 let blob_reader = self
488 .blob_service
489 .open_read(&blob_digest)
490 .await?
491 .expect("Snix bug: just-uploaded blob not found");
492
493 node.file(true, file_size, &mut BufReader::new(blob_reader))
494 .await?;
495
496 w.flush().await?;
497 }
498
499 let actual_hash = digester.finalize();
501
502 if exp_hash != actual_hash {
503 return Err(FetcherError::hash_mismatch(url, exp_hash, actual_hash));
504 }
505
506 let root_node = Node::File {
509 digest: blob_digest,
510 size: file_size,
511 executable: true,
512 };
513
514 Ok((root_node, CAHash::Nar(actual_hash), file_size))
515 }
516 Fetch::Git() => todo!(),
517 }
518 }
519
520 pub async fn ingest_and_persist<'a>(
526 &self,
527 name: &'a str,
528 fetch: Fetch,
529 ) -> Result<(StorePathRef<'a>, PathInfo), FetcherError> {
530 let (node, ca_hash, size) = self.ingest(fetch).await?;
532
533 let store_path = build_ca_path(
535 name,
536 ca_hash.mode() == CAHashMode::Nar,
537 &ca_hash.hash(),
538 [],
539 false,
540 )?;
541
542 let (nar_size, nar_sha256) = match &ca_hash {
548 CAHash::Nar(NixHash::Sha256(nar_sha256)) => (size, *nar_sha256),
549 CAHash::Nar(_) | CAHash::Flat(_) => self
550 .nar_calculation_service
551 .calculate_nar(&node)
552 .await
553 .map_err(|e| FetcherError::Io(std::io::Error::other(e)))?,
554 CAHash::Text(_) => unreachable!("Snix bug: fetch returned CAHash::Text"),
555 };
556
557 let path_info = PathInfo {
559 store_path: store_path.to_owned(),
560 node: node.clone(),
561 references: vec![],
562 nar_size,
563 nar_sha256,
564 signatures: vec![],
565 deriver: None,
566 ca: Some(ca_hash),
567 };
568
569 self.path_info_service
570 .put(path_info.clone())
571 .await
572 .map_err(|e| FetcherError::Io(std::io::Error::other(e)))?;
573
574 Ok((store_path, path_info))
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 mod fetch {
581 use super::super::*;
582 use crate::fetchers::Fetch;
583 use nix_compat::{nixbase32, nixhash::NixHash};
584 use rstest::rstest;
585
586 #[rstest]
587 #[case::url_no_hash(
588 Fetch::URL{
589 url: Url::parse("https://raw.githubusercontent.com/aaptel/notmuch-extract-patch/f732a53e12a7c91a06755ebfab2007adc9b3063b/notmuch-extract-patch").unwrap(),
590 exp_hash: None,
591 },
592 None,
593 "notmuch-extract-patch"
594 )]
595 #[case::url_sha256(
596 Fetch::URL{
597 url: Url::parse("https://raw.githubusercontent.com/aaptel/notmuch-extract-patch/f732a53e12a7c91a06755ebfab2007adc9b3063b/notmuch-extract-patch").unwrap(),
598 exp_hash: Some(NixHash::from_sri("sha256-Xa1Jbl2Eq5+L0ww+Ph1osA3Z/Dxe/RkN1/dITQCdXFk=").unwrap()),
599 },
600 Some(StorePathRef::from_bytes(b"06qi00hylriyfm0nl827crgjvbax84mz-notmuch-extract-patch").unwrap()),
601 "notmuch-extract-patch"
602 )]
603 #[case::url_custom_name(
604 Fetch::URL{
605 url: Url::parse("https://test.example/owo").unwrap(),
606 exp_hash: Some(NixHash::from_sri("sha256-Xa1Jbl2Eq5+L0ww+Ph1osA3Z/Dxe/RkN1/dITQCdXFk=").unwrap()),
607 },
608 Some(StorePathRef::from_bytes(b"06qi00hylriyfm0nl827crgjvbax84mz-notmuch-extract-patch").unwrap()),
609 "notmuch-extract-patch"
610 )]
611 #[case::nar_sha256(
612 Fetch::NAR{
613 url: Url::parse("https://cache.nixos.org/nar/0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap(),
614 hash: NixHash::from_sri("sha256-oj6yfWKbcEerK8D9GdPJtIAOveNcsH1ztGeSARGypRA=").unwrap(),
615 },
616 Some(StorePathRef::from_bytes(b"b40vjphshq4fdgv8s3yrp0bdlafi4920-0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap()),
617 "0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz"
618 )]
619 #[case::nar_sha1(
620 Fetch::NAR{
621 url: Url::parse("https://cache.nixos.org/nar/0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap(),
622 hash: NixHash::from_sri("sha1-F/fMsgwkXF8fPCg1v9zPZ4yOFIA=").unwrap(),
623 },
624 Some(StorePathRef::from_bytes(b"8kx7fdkdbzs4fkfb57xq0cbhs20ymq2n-0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap()),
625 "0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz"
626 )]
627 #[case::nar_sha1(
628 Fetch::Executable{
629 url: Url::parse("https://cache.nixos.org/nar/0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap(),
630 hash: NixHash::from_sri("sha1-NKNeU1csW5YJ4lCeWH3Z/apppNU=").unwrap(),
631 },
632 Some(StorePathRef::from_bytes(b"y92hm2xfk1009hrq0ix80j4m5k4j4w21-0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap()),
633 "0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz"
634 )]
635 fn fetch_store_path(
636 #[case] fetch: Fetch,
637 #[case] exp_path: Option<StorePathRef>,
638 #[case] name: &str,
639 ) {
640 assert_eq!(
641 exp_path,
642 fetch.store_path(name).expect("invalid name"),
643 "unexpected calculated store path"
644 );
645 }
646
647 #[test]
648 fn fetch_tarball_store_path() {
649 let url = Url::parse("https://github.com/NixOS/nixpkgs/archive/91050ea1e57e50388fa87a3302ba12d188ef723a.tar.gz").unwrap();
650 let exp_sha256 =
651 nixbase32::decode_fixed("1hf6cgaci1n186kkkjq106ryf8mmlq9vnwgfwh625wa8hfgdn4dm")
652 .unwrap();
653 let fetch = Fetch::Tarball {
654 url,
655 exp_nar_sha256: Some(exp_sha256),
656 };
657
658 assert_eq!(
659 "7adgvk5zdfq4pwrhsm3n9lzypb12gw0g-source",
660 &fetch.store_path("source").unwrap().unwrap().to_string(),
661 )
662 }
663 }
664}