Skip to main content

snix_store/pathinfoservice/nix_http/
mod.rs

1use super::{PathInfo, PathInfoService};
2use crate::{
3    nar::{NarIngestionError, ingest_nar_and_hash},
4    pathinfoservice::{self, nix_http::castore_infused::try_infused_nar_path},
5};
6use futures::{TryStreamExt, stream::BoxStream};
7use nix_compat::{
8    narinfo::{self, NarInfo, Signature},
9    nixbase32,
10    nixhash::NixHash,
11};
12use reqwest::StatusCode;
13use snix_castore::{
14    blobservice::{self, BlobService},
15    directoryservice::{self, DirectoryService},
16    proto::{
17        blob_service_client::BlobServiceClient, directory_service_client::DirectoryServiceClient,
18    },
19};
20use snix_castore::{
21    composition::{CompositionContext, ServiceBuilder},
22    directoryservice::GRPCDirectoryService,
23};
24use std::sync::Arc;
25use tokio::io::{self, AsyncRead};
26use tonic::{async_trait, transport::Channel};
27use tracing::{Span, instrument, warn};
28use url::Url;
29
30mod castore_infused;
31
32/// NixHTTPPathInfoService acts as a bridge in between the Nix HTTP Binary cache
33/// protocol provided by Nix binary caches such as cache.nixos.org, and the Snix
34/// Store Model.
35/// It implements the [PathInfoService] trait in an interesting way:
36/// Every [PathInfoService::get] fetches the .narinfo and referred NAR file,
37/// inserting components into a [BlobService] and [DirectoryService], then
38/// returning a [PathInfo] struct with the root.
39///
40/// Due to this being quite a costly operation, clients are expected to layer
41/// this service with store composition, so they're only ingested once.
42///
43/// The client is expected to be (indirectly) using the same [BlobService] and
44/// [DirectoryService], so able to fetch referred Directories and Blobs.
45/// [PathInfoService::put] is not implemented and returns an error if called.
46/// TODO: what about reading from nix-cache-info?
47pub struct NixHTTPPathInfoService<BS: Clone, DS> {
48    instance_name: String,
49    base_url: url::Url,
50    http_client: reqwest_middleware::ClientWithMiddleware,
51
52    blob_service: BS,
53    directory_service: DS,
54
55    /// A BlobService Cache with 'far' talking to the configured endpoint over gRPC.
56    /// This is used when validating castore-infused NAR URLs in NARInfos.
57    /// We rely on Cache to *insert* into 'near'.
58    layered_blob_service: blobservice::Cache<BS, blobservice::GRPCBlobService<Channel>>,
59    /// A DirectoryService Cache with 'far' talking to the configured endpoint over gRPC.
60    /// This is used when validating castore-infused NAR URLs in NARInfos.
61    /// We rely on Cache to *insert* into 'near'.
62    layered_directory_service:
63        directoryservice::combinators::Cache<DS, GRPCDirectoryService<Channel>>,
64
65    /// An optional list of [narinfo::VerifyingKey].
66    /// If the list is not empty, the .narinfo files received need to have
67    /// correct signature by at least one of these.
68    trusted_public_keys: Vec<narinfo::VerifyingKey>,
69
70    /// Force the download of NAR files, even if there's castore-infused NAR URLs.
71    force_download_nar: bool,
72}
73
74impl<BS, DS> NixHTTPPathInfoService<BS, DS>
75where
76    BS: Clone,
77    DS: DirectoryService + Clone,
78{
79    pub fn try_build(
80        instance_name: String,
81        config: NixHTTPPathInfoServiceConfig,
82        blob_service: BS,
83        directory_service: DS,
84    ) -> Result<Self, Error> {
85        let mut trusted_public_keys = Vec::new();
86        for s in config.params.trusted_public_keys {
87            trusted_public_keys.push(
88                narinfo::VerifyingKey::parse(&s).map_err(|e| Error::ParseTrustedPublicKey(s, e))?,
89            )
90        }
91
92        let (layered_blob_service, layered_directory_service) = {
93            let grpc_url = {
94                let url_str = format!("grpc+{}", config.base_url);
95                let mut url: Url = url_str.parse().expect("url to parse");
96                url.set_path("");
97                url
98            };
99
100            let channel =
101                snix_castore::tonic::TonicConnector::from_url(&grpc_url)?.connect_expect_lazy();
102
103            let instance_name_layered = format!("{}-layered", &instance_name);
104            let instance_name_grpc = format!("{}-grpc", &instance_name);
105
106            (
107                blobservice::Cache::new(
108                    instance_name_layered.clone(),
109                    blob_service.clone(),
110                    blobservice::GRPCBlobService::from_client(
111                        instance_name_grpc.clone(),
112                        BlobServiceClient::new(channel.clone()),
113                    ),
114                ),
115                directoryservice::combinators::Cache::new(
116                    instance_name_layered,
117                    directory_service.clone(),
118                    {
119                        GRPCDirectoryService::from_client(
120                            instance_name_grpc,
121                            DirectoryServiceClient::new(channel),
122                        )
123                    },
124                ),
125            )
126        };
127
128        Ok(Self {
129            instance_name,
130            base_url: {
131                // Help https://example.com/cache survive Url::join
132                let mut base_url = config.base_url;
133                if !base_url.path().ends_with('/') {
134                    let with_slash = format!("{}/", base_url.path());
135                    base_url.set_path(&with_slash);
136                }
137                base_url
138            },
139            http_client: reqwest_middleware::ClientBuilder::new(
140                reqwest::Client::builder()
141                    .user_agent(crate::USER_AGENT)
142                    .build()
143                    .map_err(reqwest_middleware::Error::Reqwest)?,
144            )
145            .with(snix_tracing::propagate::reqwest::tracing_middleware())
146            .build(),
147            blob_service,
148            directory_service,
149
150            layered_blob_service,
151            layered_directory_service,
152
153            trusted_public_keys,
154            force_download_nar: config.params.force_download_nar,
155        })
156    }
157
158    #[instrument(level=tracing::Level::TRACE, skip_all,fields(path.digest=nixbase32::encode(&digest)),err)]
159    fn derive_narinfo_url(&self, digest: [u8; 20]) -> Result<Url, Error> {
160        let s = format!("{}.narinfo", nixbase32::encode(&digest));
161        self.base_url
162            .join(&s)
163            .map_err(|e| Error::JoinUrl(self.base_url.to_owned(), s.to_owned(), e))
164    }
165}
166
167#[derive(Debug, thiserror::Error)]
168pub enum Error {
169    #[error("wrong arguments: {0}")]
170    WrongConfig(&'static str),
171    #[error("serde-qs error: {0}")]
172    SerdeQS(#[from] serde_qs::Error),
173    #[error("unable to parse pubkey {0}")]
174    ParseTrustedPublicKey(String, nix_compat::narinfo::VerifyingKeyError),
175    #[error("unable to construct tonic channel: {0}")]
176    TonicChannel(#[from] snix_castore::tonic::Error),
177
178    #[error("unable to join URL {0} with {1}")]
179    JoinUrl(Url, String, url::ParseError),
180    #[error("reqwest error")]
181    Reqwest(#[from] reqwest_middleware::Error),
182    #[error("unable to decode NARInfo response as string")]
183    DecodeBody(reqwest::Error),
184    #[error("unable to parse NARInfo")]
185    ParseNARInfo(nix_compat::narinfo::Error),
186    #[error("no valid signature found")]
187    NoValidSignature,
188    #[error("failed to request NAR, status {0}")]
189    FailedToRequestNAR(reqwest::StatusCode),
190    #[error("unsupported NAR compression: {0}")]
191    UnsupportedNARCompression(String),
192    #[error("failed to ingest NAR")]
193    IngestNAR(NarIngestionError),
194    #[error("NARSize mismatch, narinfo size {narinfo_size}, actual size {actual_size}")]
195    NARSizeMismatch { narinfo_size: u64, actual_size: u64 },
196    #[error("NARHash mismatch, narinfo NARHash {exp}, actual NARHash {act}",
197        exp = NixHash::Sha256(*.narinfo_nar_sha256),
198        act = NixHash::Sha256(*.actual_nar_sha256))]
199    NARHashMismatch {
200        narinfo_nar_sha256: [u8; 32],
201        actual_nar_sha256: [u8; 32],
202    },
203
204    #[error("put not supported")]
205    PutNotSupported,
206    #[error("list not supported")]
207    ListNotSupported,
208}
209
210#[async_trait]
211impl<BS, DS> PathInfoService for NixHTTPPathInfoService<BS, DS>
212where
213    BS: BlobService + Send + Sync + Clone + 'static,
214    DS: DirectoryService + Send + Sync + Clone + 'static,
215{
216    #[instrument(skip_all, err, fields(
217        path.digest=nixbase32::encode(&digest),
218        instance_name=%self.instance_name,
219        narinfo.url=tracing::field::Empty,
220        nar.url=tracing::field::Empty,
221    ))]
222    async fn get(&self, digest: [u8; 20]) -> Result<Option<PathInfo>, pathinfoservice::Error> {
223        let narinfo_url = self.derive_narinfo_url(digest)?;
224
225        let span = Span::current();
226        span.record("narinfo.url", narinfo_url.to_string());
227
228        let resp = self
229            .http_client
230            .get(narinfo_url)
231            .send()
232            .await
233            .map_err(Error::Reqwest)?;
234
235        // In the case of a 404, return a NotFound.
236        // We also return a NotFound in case of a 403 - this is to match the behaviour as Nix,
237        // when querying nix-cache.s3.amazonaws.com directly, rather than cache.nixos.org.
238        if resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::FORBIDDEN {
239            return Ok(None);
240        }
241
242        let narinfo_str = resp.text().await.map_err(Error::DecodeBody)?;
243
244        // parse the received narinfo
245        let narinfo = NarInfo::parse(&narinfo_str).map_err(Error::ParseNARInfo)?;
246
247        // ensure the store path digest in the returned NARInfo matches the one we requested.
248        if narinfo.store_path.digest() != &digest {
249            return Err("Store path digest in NARInfo doesn't match".into());
250        }
251
252        // if [self.trusted_public_keys] is set, ensure there's at least one valid signature.
253        if !self.trusted_public_keys.is_empty() {
254            let fingerprint = narinfo.fingerprint();
255
256            if !self.trusted_public_keys.iter().any(|pubkey| {
257                narinfo
258                    .signatures
259                    .iter()
260                    .any(|sig| pubkey.verify(&fingerprint, sig))
261            }) {
262                Err(Error::NoValidSignature)?
263            }
264        }
265
266        // FUTUREWORK: Keep some database around mapping from narsha256 to
267        // (unnamed) rootnode, so we can use that and avoid downloading the same
268        // NAR a second time.
269
270        // To construct the full PathInfo, we also need to populate the node field,
271        // and for this we need to download the NAR file and ingest it into castore.
272        // We can use a shortcut - if the NAR URL is castore-infused we can try
273        // that route and maybe save some downloading, as we can leverage already locally present castore subnodes.
274        // Get the root node, either by using the infused nar path or by ingesting the entire NAR.
275        let root_node = if !self.force_download_nar
276            && let Some(root_node) = try_infused_nar_path(
277                &narinfo,
278                self.layered_blob_service.clone(),
279                &self.layered_directory_service,
280            )
281            .await
282            .unwrap_or_else(|err| {
283                warn!(%err, "unable to use infused store path");
284                None
285            }) {
286            root_node
287        } else {
288            // create a request for the NAR file itself.
289            let nar_url = self
290                .base_url
291                .join(narinfo.url)
292                .map_err(|e| Error::JoinUrl(self.base_url.clone(), narinfo.url.to_owned(), e))?;
293            span.record("nar.url", nar_url.to_string());
294
295            let resp = self
296                .http_client
297                .get(nar_url.clone())
298                .send()
299                .await
300                .map_err(Error::Reqwest)?;
301
302            // if the request is not successful, return an error.
303            if !resp.status().is_success() {
304                Err(Error::FailedToRequestNAR(resp.status()))?;
305            }
306
307            // get a reader of the response body.
308            let r = tokio_util::io::StreamReader::new(resp.bytes_stream().map_err(|e| {
309                let e = e.without_url();
310                warn!(e=%e, "failed to get response body");
311                io::Error::new(io::ErrorKind::BrokenPipe, e.to_string())
312            }));
313
314            // handle decompression, depending on the compression field.
315            let mut r: Box<dyn AsyncRead + Send + Unpin> = match narinfo.compression {
316                None => Box::new(r) as Box<dyn AsyncRead + Send + Unpin>,
317                Some("bzip2") => Box::new(async_compression::tokio::bufread::BzDecoder::new(r))
318                    as Box<dyn AsyncRead + Send + Unpin>,
319                Some("gzip") => Box::new(async_compression::tokio::bufread::GzipDecoder::new(r))
320                    as Box<dyn AsyncRead + Send + Unpin>,
321                Some("xz") => Box::new(async_compression::tokio::bufread::XzDecoder::new(r))
322                    as Box<dyn AsyncRead + Send + Unpin>,
323                Some("zstd") => {
324                    // NARs are often many concatenated zstd frames; the default decoder
325                    // stops after the first.
326                    let mut decoder = async_compression::tokio::bufread::ZstdDecoder::new(r);
327                    decoder.multiple_members(true);
328                    Box::new(decoder) as Box<dyn AsyncRead + Send + Unpin>
329                }
330                Some(comp_str) => Err(Error::UnsupportedNARCompression(comp_str.to_owned()))?,
331            };
332
333            let (root_node, nar_hash, nar_size) = ingest_nar_and_hash(
334                self.blob_service.clone(),
335                &self.directory_service,
336                &mut r,
337                &narinfo.ca,
338            )
339            .await
340            .map_err(Error::IngestNAR)?;
341
342            // ensure the ingested narhash and narsize do actually match.
343            if narinfo.nar_size != nar_size {
344                Err(Error::NARSizeMismatch {
345                    narinfo_size: narinfo.nar_size,
346                    actual_size: nar_size,
347                })?
348            }
349            if narinfo.nar_hash != nar_hash {
350                Err(Error::NARHashMismatch {
351                    narinfo_nar_sha256: narinfo.nar_hash,
352                    actual_nar_sha256: nar_hash,
353                })?
354            }
355            root_node
356        };
357
358        Ok(Some(PathInfo {
359            store_path: narinfo.store_path.to_owned(),
360            node: root_node,
361            references: narinfo.references.iter().map(|sp| sp.to_owned()).collect(),
362            nar_size: narinfo.nar_size,
363            nar_sha256: narinfo.nar_hash,
364            deriver: narinfo.deriver.as_ref().map(|sp| sp.to_owned()),
365            signatures: narinfo
366                .signatures
367                .into_iter()
368                .map(|s| Signature::<String>::new(s.name().to_string(), s.bytes().to_owned()))
369                .collect(),
370            ca: narinfo.ca,
371        }))
372    }
373
374    #[instrument(skip_all, err, fields(
375        path.digest=nixbase32::encode(&digest),
376        instance_name=%self.instance_name,
377        narinfo.url=tracing::field::Empty,
378    ))]
379    async fn has(&self, digest: [u8; 20]) -> Result<bool, pathinfoservice::Error> {
380        let narinfo_url = self.derive_narinfo_url(digest)?;
381
382        let span = Span::current();
383        span.record("narinfo.url", narinfo_url.to_string());
384
385        let resp = self
386            .http_client
387            .head(narinfo_url)
388            .send()
389            .await
390            .map_err(Error::Reqwest)?;
391
392        // In the case of a 404, return a NotFound.
393        // We also return a NotFound in case of a 403 - this is to match the behaviour as Nix,
394        // when querying nix-cache.s3.amazonaws.com directly, rather than cache.nixos.org.
395        if resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::FORBIDDEN {
396            Ok(false)
397        } else {
398            Ok(true)
399        }
400    }
401
402    #[instrument(skip_all, fields(path_info=?_path_info, instance_name=%self.instance_name))]
403    async fn put(&self, _path_info: PathInfo) -> Result<PathInfo, pathinfoservice::Error> {
404        Err(Box::new(Error::PutNotSupported))
405    }
406
407    fn list(&self) -> BoxStream<'static, Result<PathInfo, pathinfoservice::Error>> {
408        Box::pin(futures::stream::once(async {
409            Err(Error::ListNotSupported)?
410        }))
411    }
412}
413
414#[derive(serde::Deserialize, Clone, Debug, PartialEq, Eq)]
415#[serde(deny_unknown_fields)]
416pub struct NixHTTPPathInfoServiceConfig {
417    base_url: Url,
418
419    #[serde(flatten)]
420    params: NixHTTPPathInfoServiceParams,
421}
422
423#[derive(serde::Deserialize, Clone, Debug, PartialEq, Eq)]
424#[serde(deny_unknown_fields)]
425struct NixHTTPPathInfoServiceParams {
426    #[serde(default = "default_blob_service")]
427    blob_service: String,
428    #[serde(default = "default_directory_service")]
429    directory_service: String,
430    #[serde(default)]
431    /// An optional list of [narinfo::VerifyingKey].
432    /// If not empty, the .narinfo files received need to have correct signature by at least one of these.
433    trusted_public_keys: Vec<String>,
434
435    #[serde(default)]
436    /// Force the download of NAR files, even if there's castore-infused NAR URLs.
437    force_download_nar: bool,
438}
439
440fn default_blob_service() -> String {
441    "&root".to_string()
442}
443fn default_directory_service() -> String {
444    "&root".to_string()
445}
446
447impl TryFrom<Url> for NixHTTPPathInfoServiceConfig {
448    type Error = Box<dyn std::error::Error + Send + Sync>;
449    fn try_from(url: Url) -> Result<Self, Self::Error> {
450        let scheme = url
451            .scheme()
452            .strip_prefix("nix+")
453            .ok_or_else(|| Error::WrongConfig("scheme must start with nix+"))?;
454
455        if !url.has_authority() {
456            Err(Error::WrongConfig("url must have authority component"))?
457        }
458        if !url.has_host() {
459            Err(Error::WrongConfig("url must have host component"))?
460        }
461        if !["http", "https"].contains(&scheme) {
462            Err(Error::WrongConfig("unknown scheme"))?
463        }
464
465        Ok(NixHTTPPathInfoServiceConfig {
466            // Stringify the URL and remove the nix+ prefix.
467            // We can't use `url.set_scheme(rest)`, as it disallows
468            // setting something http(s) that previously wasn't.
469            // Also make sure to drop the query, we don't want to leak our
470            // config to the remote HTTP endpoint we query.
471            base_url: {
472                let mut url: Url = url
473                    .to_string()
474                    .strip_prefix("nix+")
475                    .unwrap()
476                    .parse()
477                    .expect("stripped URL to parse again");
478                url.set_query(None);
479                url
480            },
481            params: serde_qs::from_str(url.query().unwrap_or_default())?,
482        })
483    }
484}
485
486#[async_trait]
487impl ServiceBuilder for NixHTTPPathInfoServiceConfig {
488    type Output = dyn PathInfoService;
489    async fn build<'a>(
490        &'a self,
491        instance_name: &str,
492        context: &CompositionContext,
493    ) -> Result<Arc<Self::Output>, Box<dyn std::error::Error + Send + Sync + 'static>> {
494        let (blob_service, directory_service) = futures::join!(
495            context.resolve::<dyn BlobService>(&self.params.blob_service),
496            context.resolve::<dyn DirectoryService>(&self.params.directory_service)
497        );
498        let svc = NixHTTPPathInfoService::try_build(
499            instance_name.to_string(),
500            self.to_owned(),
501            blob_service?,
502            directory_service?,
503        )?;
504        Ok(Arc::new(svc))
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::{NixHTTPPathInfoServiceConfig, NixHTTPPathInfoServiceParams};
511    use rstest::rstest;
512    use url::Url;
513
514    #[rstest]
515    /// Correct Scheme for the cache.nixos.org binary cache.
516    #[case::correct_nix_https("nix+https://cache.nixos.org", Some(
517        NixHTTPPathInfoServiceConfig {
518            base_url: "https://cache.nixos.org".try_into().unwrap(),
519            params: NixHTTPPathInfoServiceParams {
520                blob_service: "&root".to_string(),
521                directory_service: "&root".to_string(),
522                trusted_public_keys: vec![],
523                force_download_nar: false,
524            }
525        }
526    ))]
527    /// Correct Scheme for the cache.nixos.org binary cache (HTTP URL).
528    #[case::correct_nix_http("nix+http://cache.nixos.org", Some(
529        NixHTTPPathInfoServiceConfig {
530            base_url: "http://cache.nixos.org".try_into().unwrap(),
531            params: NixHTTPPathInfoServiceParams {
532                blob_service: "&root".to_string(),
533                directory_service: "&root".to_string(),
534                trusted_public_keys: vec![],
535                force_download_nar: false,
536            }
537        }
538    ))]
539    /// Correct Scheme for Nix HTTP Binary cache, with a subpath.
540    #[case::correct_nix_http_with_subpath("nix+http://192.0.2.1/foo", Some(
541        NixHTTPPathInfoServiceConfig {
542            base_url: "http://192.0.2.1/foo".try_into().unwrap(),
543            params: NixHTTPPathInfoServiceParams {
544                blob_service: "&root".to_string(),
545                directory_service: "&root".to_string(),
546                trusted_public_keys: vec![],
547                force_download_nar: false,
548            }
549        }
550    ))]
551    /// Correct Scheme for Nix HTTP Binary cache, with a subpath and port.
552    #[case::correct_nix_http_with_subpath_and_port("nix+http://[::1]:8080/foo", Some(
553        NixHTTPPathInfoServiceConfig {
554            base_url: "http://[::1]:8080/foo".try_into().unwrap(),
555            params: NixHTTPPathInfoServiceParams {
556                blob_service: "&root".to_string(),
557                directory_service: "&root".to_string(),
558                trusted_public_keys: vec![],
559                force_download_nar: false,
560            }
561        }
562
563    ))]
564    /// Correct Scheme for the cache.nixos.org binary cache, and correct trusted public key set
565    #[case::correct_nix_https_with_trusted_public_key(
566        "nix+https://cache.nixos.org?trusted_public_keys[0]=cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=", Some(
567        NixHTTPPathInfoServiceConfig {
568            base_url: "https://cache.nixos.org".try_into().unwrap(),
569            params: NixHTTPPathInfoServiceParams {
570                blob_service: "&root".to_string(),
571                directory_service: "&root".to_string(),
572                trusted_public_keys: vec![
573                    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=".to_string()
574                ],
575                force_download_nar: false,
576            }
577        }
578    ))]
579    /// Correct Scheme for the cache.nixos.org binary cache, and two correct trusted public keys set
580    #[case::correct_nix_https_with_two_trusted_public_keys(
581        "nix+https://cache.nixos.org?trusted_public_keys[0]=cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=&trusted_public_keys[1]=foo:jp4fCEx9tBEId/L0ZsVJ26k0wC0fu7vJqLjjIGFkup8=", Some(
582        NixHTTPPathInfoServiceConfig {
583            base_url: "https://cache.nixos.org".try_into().unwrap(),
584            params: NixHTTPPathInfoServiceParams {
585                blob_service: "&root".to_string(),
586                directory_service: "&root".to_string(),
587                trusted_public_keys: vec![
588                    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=".to_string(),
589                    "foo:jp4fCEx9tBEId/L0ZsVJ26k0wC0fu7vJqLjjIGFkup8=".to_string()
590                ],
591                force_download_nar: false,
592            }
593        }
594    ))]
595    #[case::wrong_scheme("nix+grpc://example.com", None)]
596    #[case::missing_host("nix+http:///", None)]
597    #[case::missing_authority("nix+http:", None)]
598    /// Correct cache.nixos.org binary cache URL, but wrong `trusted_public_keys` param usage (should be list)
599    #[case::trusted_public_keys_no_sequence(
600        "nix+https://cache.nixos.org?trusted_public_keys=cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=",
601        None
602    )]
603    /// Correct cache.nixos.org binary cache URL, but wrong param name
604    #[case::trusted_public_keys_wrong_pubkey(
605        "nix+https://cache.nixos.org?trustedpublickeys=cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=",
606        None
607    )]
608    fn parse_url(#[case] url_str: &str, #[case] exp_config: Option<NixHTTPPathInfoServiceConfig>) {
609        let url: Url = url_str.parse().expect("url to parse");
610
611        match (NixHTTPPathInfoServiceConfig::try_from(url), exp_config) {
612            (Ok(_), None) => panic!("parsing url unexpectedly succeeded"),
613            (Ok(config), Some(exp_config)) => assert_eq!(exp_config, config),
614            (Err(_), None) => {}
615            (Err(e), Some(_)) => panic!("parsing url unexpectedly failed: {e}"),
616        }
617    }
618}