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::hash_mismatch(url, exp_hash, actual_hash));
364                }
365
366                // Construct and return the FileNode describing the downloaded contents.
367                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                // Construct a AsyncRead reading from the data as its downloaded.
382                // NOTE: For Fetch::Tarball, the expected NAR SHA256 is specified, so we cannot use the hashed_mirrors mechanism.
383                let r = self.download(url.clone(), None).await?;
384
385                // Pop compression, if it exists.
386                let r = DecompressedReader::new(r).await?;
387
388                // Ingest the archive, get the root node.
389                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                // If an expected NAR sha256 was provided, compare with the one
397                // calculated from our root node.
398                // Even if no expected NAR sha256 has been provided, we need
399                // the actual one to calculate the store path.
400                let (nar_size, actual_nar_sha256) = self
401                    .nar_calculation_service
402                    .calculate_nar(&node)
403                    .await
404                    .map_err(|e| {
405                        // convert the generic Store error to an IO error.
406                        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                // Construct a AsyncRead reading from the data as its downloaded.
430                let r = self.download(url.clone(), Some(&exp_hash)).await?;
431
432                // Pop compression, if it exists.
433                let mut r = DecompressedReader::new(r).await?;
434
435                // Ingest the NAR, get the root node.
436                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                    // use a CAHash::Nar with the algo from the input.
453                    CAHash::Nar(exp_hash),
454                    actual_nar_size,
455                ))
456            }
457            Fetch::Executable {
458                url,
459                hash: exp_hash,
460            } => {
461                // Construct a AsyncRead reading from the data as its downloaded.
462                let mut r = self.download(url.clone(), Some(&exp_hash)).await?;
463
464                // Construct a AsyncWrite to write into the BlobService.
465                let mut blob_writer = self.blob_service.open_write().await;
466
467                // Copy the contents from the download reader to the blob writer.
468                let file_size = tokio::io::copy(&mut r, &mut blob_writer).await?;
469                let blob_digest = blob_writer.close().await?;
470
471                // Render the NAR representation on-the-fly into a hash function with
472                // the same algo as our expected hash.
473                // We cannot do this upfront, as we don't know the actual size.
474                // FUTUREWORK: make opportunistic use of Content-Length header?
475
476                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                // finalize the hasher.
500                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                // Construct and return the FileNode describing the downloaded contents,
507                // make it executable.
508                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    /// Ingests the data from a specified [Fetch], persists the returned node
521    /// in the PathInfoService, and returns the calculated StorePath, as well as
522    /// the root node pointing to the contents.
523    /// The root node can be used to descend into the data without doing the
524    /// lookup to the PathInfoService again.
525    pub async fn ingest_and_persist<'a>(
526        &self,
527        name: &'a str,
528        fetch: Fetch,
529    ) -> Result<(StorePathRef<'a>, PathInfo), FetcherError> {
530        // Fetch file, return the (unnamed) (File)Node of its contents, ca hash and filesize.
531        let (node, ca_hash, size) = self.ingest(fetch).await?;
532
533        // Calculate the store path to return, by calculating from ca_hash.
534        let store_path = build_ca_path(
535            name,
536            ca_hash.mode() == CAHashMode::Nar,
537            &ca_hash.hash(),
538            [],
539            false,
540        )?;
541
542        // If the resulting hash is not a CAHash::Nar, we also need to invoke
543        // `calculate_nar` to calculate this representation, as it's required in
544        // the [PathInfo].
545        // FUTUREWORK: allow ingest() to return multiple hashes, or have it feed
546        // nar_calculation_service too?
547        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        // Construct the PathInfo and persist it.
558        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}