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::HashMismatch {
364 url,
365 wanted: exp_hash,
366 got: actual_hash,
367 });
368 }
369
370 Ok((
372 Node::File {
373 digest: blob_writer.close().await?,
374 size: blob_size,
375 executable: false,
376 },
377 CAHash::Flat(actual_hash),
378 blob_size,
379 ))
380 }
381 Fetch::Tarball {
382 url,
383 exp_nar_sha256,
384 } => {
385 let r = self.download(url.clone(), None).await?;
388
389 let r = DecompressedReader::new(r).await?;
391
392 let node = snix_castore::import::archive::ingest_archive(
394 self.blob_service.clone(),
395 self.directory_service.clone(),
396 r,
397 )
398 .await?;
399
400 let (nar_size, actual_nar_sha256) = self
405 .nar_calculation_service
406 .calculate_nar(&node)
407 .await
408 .map_err(|e| {
409 FetcherError::Io(std::io::Error::other(e))
411 })?;
412
413 if let Some(exp_nar_sha256) = exp_nar_sha256
414 && exp_nar_sha256 != actual_nar_sha256
415 {
416 return Err(FetcherError::HashMismatch {
417 url,
418 wanted: NixHash::Sha256(exp_nar_sha256),
419 got: NixHash::Sha256(actual_nar_sha256),
420 });
421 }
422
423 Ok((
424 node,
425 CAHash::Nar(NixHash::Sha256(actual_nar_sha256)),
426 nar_size,
427 ))
428 }
429 Fetch::NAR {
430 url,
431 hash: exp_hash,
432 } => {
433 let r = self.download(url.clone(), Some(&exp_hash)).await?;
435
436 let mut r = DecompressedReader::new(r).await?;
438
439 let (root_node, _actual_nar_sha256, actual_nar_size) =
441 snix_store::nar::ingest_nar_and_hash(
442 self.blob_service.clone(),
443 self.directory_service.clone(),
444 &mut r,
445 &Some(CAHash::Nar(exp_hash.clone())),
446 )
447 .await
448 .map_err(|e| match e {
449 NarIngestionError::HashMismatch { expected, actual } => {
450 FetcherError::HashMismatch {
451 url,
452 wanted: expected,
453 got: actual,
454 }
455 }
456 _ => FetcherError::Io(std::io::Error::other(e.to_string())),
457 })?;
458 Ok((
459 root_node,
460 CAHash::Nar(exp_hash),
462 actual_nar_size,
463 ))
464 }
465 Fetch::Executable {
466 url,
467 hash: exp_hash,
468 } => {
469 let mut r = self.download(url.clone(), Some(&exp_hash)).await?;
471
472 let mut blob_writer = self.blob_service.open_write().await;
474
475 let file_size = tokio::io::copy(&mut r, &mut blob_writer).await?;
477 let blob_digest = blob_writer.close().await?;
478
479 let w = tokio::io::sink();
485 let mut digester = NixHashDigester::new(exp_hash.algo());
486 let mut nar_size: u64 = 0;
487 let mut w = InspectWriter::new(w, |d| {
488 digester.update(d);
489 nar_size += d.len() as u64;
490 });
491
492 {
493 let node = nix_compat::nar::writer::r#async::open(&mut w).await?;
494
495 let blob_reader = self
496 .blob_service
497 .open_read(&blob_digest)
498 .await?
499 .expect("Snix bug: just-uploaded blob not found");
500
501 node.file(true, file_size, &mut BufReader::new(blob_reader))
502 .await?;
503
504 w.flush().await?;
505 }
506
507 let actual_hash = digester.finalize();
509
510 if exp_hash != actual_hash {
511 return Err(FetcherError::HashMismatch {
512 url,
513 wanted: exp_hash,
514 got: actual_hash,
515 });
516 }
517
518 let root_node = Node::File {
521 digest: blob_digest,
522 size: file_size,
523 executable: true,
524 };
525
526 Ok((root_node, CAHash::Nar(actual_hash), file_size))
527 }
528 Fetch::Git() => todo!(),
529 }
530 }
531
532 pub async fn ingest_and_persist<'a>(
538 &self,
539 name: &'a str,
540 fetch: Fetch,
541 ) -> Result<(StorePathRef<'a>, PathInfo), FetcherError> {
542 let (node, ca_hash, size) = self.ingest(fetch).await?;
544
545 let store_path = build_ca_path(
547 name,
548 ca_hash.mode() == CAHashMode::Nar,
549 &ca_hash.hash(),
550 [],
551 false,
552 )?;
553
554 let (nar_size, nar_sha256) = match &ca_hash {
560 CAHash::Nar(NixHash::Sha256(nar_sha256)) => (size, *nar_sha256),
561 CAHash::Nar(_) | CAHash::Flat(_) => self
562 .nar_calculation_service
563 .calculate_nar(&node)
564 .await
565 .map_err(|e| FetcherError::Io(std::io::Error::other(e)))?,
566 CAHash::Text(_) => unreachable!("Snix bug: fetch returned CAHash::Text"),
567 };
568
569 let path_info = PathInfo {
571 store_path: store_path.to_owned(),
572 node: node.clone(),
573 references: vec![],
574 nar_size,
575 nar_sha256,
576 signatures: vec![],
577 deriver: None,
578 ca: Some(ca_hash),
579 };
580
581 self.path_info_service
582 .put(path_info.clone())
583 .await
584 .map_err(|e| FetcherError::Io(std::io::Error::other(e)))?;
585
586 Ok((store_path, path_info))
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 mod fetch {
593 use super::super::*;
594 use crate::fetchers::Fetch;
595 use nix_compat::{nixbase32, nixhash::NixHash};
596 use rstest::rstest;
597
598 #[rstest]
599 #[case::url_no_hash(
600 Fetch::URL{
601 url: Url::parse("https://raw.githubusercontent.com/aaptel/notmuch-extract-patch/f732a53e12a7c91a06755ebfab2007adc9b3063b/notmuch-extract-patch").unwrap(),
602 exp_hash: None,
603 },
604 None,
605 "notmuch-extract-patch"
606 )]
607 #[case::url_sha256(
608 Fetch::URL{
609 url: Url::parse("https://raw.githubusercontent.com/aaptel/notmuch-extract-patch/f732a53e12a7c91a06755ebfab2007adc9b3063b/notmuch-extract-patch").unwrap(),
610 exp_hash: Some(NixHash::from_sri("sha256-Xa1Jbl2Eq5+L0ww+Ph1osA3Z/Dxe/RkN1/dITQCdXFk=").unwrap()),
611 },
612 Some(StorePathRef::from_bytes(b"06qi00hylriyfm0nl827crgjvbax84mz-notmuch-extract-patch").unwrap()),
613 "notmuch-extract-patch"
614 )]
615 #[case::url_custom_name(
616 Fetch::URL{
617 url: Url::parse("https://test.example/owo").unwrap(),
618 exp_hash: Some(NixHash::from_sri("sha256-Xa1Jbl2Eq5+L0ww+Ph1osA3Z/Dxe/RkN1/dITQCdXFk=").unwrap()),
619 },
620 Some(StorePathRef::from_bytes(b"06qi00hylriyfm0nl827crgjvbax84mz-notmuch-extract-patch").unwrap()),
621 "notmuch-extract-patch"
622 )]
623 #[case::nar_sha256(
624 Fetch::NAR{
625 url: Url::parse("https://cache.nixos.org/nar/0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap(),
626 hash: NixHash::from_sri("sha256-oj6yfWKbcEerK8D9GdPJtIAOveNcsH1ztGeSARGypRA=").unwrap(),
627 },
628 Some(StorePathRef::from_bytes(b"b40vjphshq4fdgv8s3yrp0bdlafi4920-0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap()),
629 "0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz"
630 )]
631 #[case::nar_sha1(
632 Fetch::NAR{
633 url: Url::parse("https://cache.nixos.org/nar/0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap(),
634 hash: NixHash::from_sri("sha1-F/fMsgwkXF8fPCg1v9zPZ4yOFIA=").unwrap(),
635 },
636 Some(StorePathRef::from_bytes(b"8kx7fdkdbzs4fkfb57xq0cbhs20ymq2n-0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap()),
637 "0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz"
638 )]
639 #[case::nar_sha1(
640 Fetch::Executable{
641 url: Url::parse("https://cache.nixos.org/nar/0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap(),
642 hash: NixHash::from_sri("sha1-NKNeU1csW5YJ4lCeWH3Z/apppNU=").unwrap(),
643 },
644 Some(StorePathRef::from_bytes(b"y92hm2xfk1009hrq0ix80j4m5k4j4w21-0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz").unwrap()),
645 "0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz"
646 )]
647 fn fetch_store_path(
648 #[case] fetch: Fetch,
649 #[case] exp_path: Option<StorePathRef>,
650 #[case] name: &str,
651 ) {
652 assert_eq!(
653 exp_path,
654 fetch.store_path(name).expect("invalid name"),
655 "unexpected calculated store path"
656 );
657 }
658
659 #[test]
660 fn fetch_tarball_store_path() {
661 let url = Url::parse("https://github.com/NixOS/nixpkgs/archive/91050ea1e57e50388fa87a3302ba12d188ef723a.tar.gz").unwrap();
662 let exp_sha256 =
663 nixbase32::decode_fixed("1hf6cgaci1n186kkkjq106ryf8mmlq9vnwgfwh625wa8hfgdn4dm")
664 .unwrap();
665 let fetch = Fetch::Tarball {
666 url,
667 exp_nar_sha256: Some(exp_sha256),
668 };
669
670 assert_eq!(
671 "7adgvk5zdfq4pwrhsm3n9lzypb12gw0g-source",
672 &fetch.store_path("source").unwrap().unwrap().to_string(),
673 )
674 }
675 }
676}