Skip to main content

snix_build_glue/fetchers/
mod.rs

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/// Representing options for doing a fetch.
23#[derive(Clone, Eq, PartialEq)]
24pub enum Fetch {
25    /// Fetch a literal file from the given URL,
26    /// with an optional expected hash.
27    URL {
28        /// The URL to fetch from.
29        url: Url,
30        /// The expected hash of the file.
31        exp_hash: Option<NixHash>,
32    },
33
34    /// Fetch a tarball from the given URL and unpack.
35    /// The file must be a tape archive (.tar), optionally compressed with gzip,
36    /// bzip2 or xz.
37    /// The top-level path component of the files in the tarball is removed,
38    /// so it is best if the tarball contains a single directory at top level.
39    /// Optionally, a sha256 digest can be provided to verify the unpacked
40    /// contents against.
41    Tarball {
42        /// The URL to fetch from.
43        url: Url,
44        /// The expected hash of the contents, as NAR.
45        exp_nar_sha256: Option<[u8; 32]>,
46    },
47
48    /// Fetch a NAR file from the given URL and unpack.
49    /// The file can optionally be compressed.
50    NAR {
51        /// The URL to fetch from.
52        url: Url,
53        /// The expected hash of the NAR representation.
54        /// This unfortunately supports more than sha256.
55        hash: NixHash,
56    },
57
58    /// Fetches a file at a URL, makes it the store path root node,
59    /// but executable.
60    /// Used by <nix/fetchurl.nix>, with `executable = true;`.
61    /// The expected hash is over the NAR representation, but can be not SHA256:
62    /// ```nix
63    /// (import <nix/fetchurl.nix> { url = "https://cache.nixos.org/nar/0r8nqa1klm5v17ifc6z96m9wywxkjvgbnqq9pmy0sgqj53wj3n12.nar.xz"; hash = "sha1-NKNeU1csW5YJ4lCeWH3Z/apppNU="; executable = true; })
64    /// ```
65    Executable {
66        /// The URL to fetch from.
67        url: Url,
68        /// The expected hash of the NAR representation.
69        /// This unfortunately supports more than sha256.
70        hash: NixHash,
71    },
72
73    /// TODO
74    Git(),
75}
76
77// Drops potentially sensitive username and password from a URL.
78fn 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    /// If the [Fetch] contains an expected hash upfront, returns the resulting
133    /// store path.
134    /// This doesn't do any fetching.
135    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            // everything else
157            Fetch::URL { exp_hash: None, .. }
158            | Fetch::Tarball {
159                exp_nar_sha256: None,
160                ..
161            } => return Ok(None),
162        };
163
164        // calculate the store path of this fetch
165        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
176/// Knows how to fetch a given [Fetch].
177pub 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    /// Downloads single url.
208    ///
209    /// Returns an error on any IO failures and if http response status is not 200.
210    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                    // "Returns Err if the host is neither empty nor "localhost"
219                    // (except on Windows, where file: URLs may have a non-local host)"
220                    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    /// Constructs a HTTP request to the passed URL, and returns a AsyncReadBuf to it.
292    /// In case the URI uses the file:// scheme, use tokio::fs to open it.
293    ///
294    /// HTTP downloads with exp_hash present prioritize downloading from hashed_mirrors, if any,
295    /// by using the following download url: {hashed_mirror}/{hash_algo}/{hexdigest}.
296    /// Note that exp_hash is the flat hash of the literal file contents, not the nar hash.
297    #[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            // TOOD: maybe shorten
307            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                // The only reason this can fail is if the 'algo/hash' path cannot be parsed
317                // which cannot happen.
318                .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    /// Ingest the data from a specified [Fetch].
338    /// On success, return the root node, a content digest and length.
339    /// Returns an error if there was a failure during fetching, or the contents
340    /// didn't match the previously communicated hash contained inside the FetchArgs.
341    pub async fn ingest(&self, fetch: Fetch) -> Result<(Node, CAHash, u64), FetcherError> {
342        match fetch {
343            Fetch::URL { url, exp_hash } => {
344                // Construct a AsyncRead reading from the data as its downloaded.
345                let mut r = self.download(url.clone(), exp_hash.as_ref()).await?;
346
347                // Construct a AsyncWrite to write into the BlobService.
348                let mut blob_writer = self.blob_service.open_write().await;
349
350                // Copy the contents from the download reader to the blob writer.
351                // Calculate the digest of the file received, depending on the
352                // communicated expected hash algo (or sha256 if none provided).
353                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                // Construct and return the FileNode describing the downloaded contents.
371                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                // Construct a AsyncRead reading from the data as its downloaded.
386                // NOTE: For Fetch::Tarball, the expected NAR SHA256 is specified, so we cannot use the hashed_mirrors mechanism.
387                let r = self.download(url.clone(), None).await?;
388
389                // Pop compression, if it exists.
390                let r = DecompressedReader::new(r).await?;
391
392                // Ingest the archive, get the root node.
393                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                // If an expected NAR sha256 was provided, compare with the one
401                // calculated from our root node.
402                // Even if no expected NAR sha256 has been provided, we need
403                // the actual one to calculate the store path.
404                let (nar_size, actual_nar_sha256) = self
405                    .nar_calculation_service
406                    .calculate_nar(&node)
407                    .await
408                    .map_err(|e| {
409                        // convert the generic Store error to an IO error.
410                        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                // Construct a AsyncRead reading from the data as its downloaded.
434                let r = self.download(url.clone(), Some(&exp_hash)).await?;
435
436                // Pop compression, if it exists.
437                let mut r = DecompressedReader::new(r).await?;
438
439                // Ingest the NAR, get the root node.
440                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                    // use a CAHash::Nar with the algo from the input.
461                    CAHash::Nar(exp_hash),
462                    actual_nar_size,
463                ))
464            }
465            Fetch::Executable {
466                url,
467                hash: exp_hash,
468            } => {
469                // Construct a AsyncRead reading from the data as its downloaded.
470                let mut r = self.download(url.clone(), Some(&exp_hash)).await?;
471
472                // Construct a AsyncWrite to write into the BlobService.
473                let mut blob_writer = self.blob_service.open_write().await;
474
475                // Copy the contents from the download reader to the blob writer.
476                let file_size = tokio::io::copy(&mut r, &mut blob_writer).await?;
477                let blob_digest = blob_writer.close().await?;
478
479                // Render the NAR representation on-the-fly into a hash function with
480                // the same algo as our expected hash.
481                // We cannot do this upfront, as we don't know the actual size.
482                // FUTUREWORK: make opportunistic use of Content-Length header?
483
484                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                // finalize the hasher.
508                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                // Construct and return the FileNode describing the downloaded contents,
519                // make it executable.
520                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    /// Ingests the data from a specified [Fetch], persists the returned node
533    /// in the PathInfoService, and returns the calculated StorePath, as well as
534    /// the root node pointing to the contents.
535    /// The root node can be used to descend into the data without doing the
536    /// lookup to the PathInfoService again.
537    pub async fn ingest_and_persist<'a>(
538        &self,
539        name: &'a str,
540        fetch: Fetch,
541    ) -> Result<(StorePathRef<'a>, PathInfo), FetcherError> {
542        // Fetch file, return the (unnamed) (File)Node of its contents, ca hash and filesize.
543        let (node, ca_hash, size) = self.ingest(fetch).await?;
544
545        // Calculate the store path to return, by calculating from ca_hash.
546        let store_path = build_ca_path(
547            name,
548            ca_hash.mode() == CAHashMode::Nar,
549            &ca_hash.hash(),
550            [],
551            false,
552        )?;
553
554        // If the resulting hash is not a CAHash::Nar, we also need to invoke
555        // `calculate_nar` to calculate this representation, as it's required in
556        // the [PathInfo].
557        // FUTUREWORK: allow ingest() to return multiple hashes, or have it feed
558        // nar_calculation_service too?
559        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        // Construct the PathInfo and persist it.
570        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}