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
32pub 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 layered_blob_service: blobservice::Cache<BS, blobservice::GRPCBlobService<Channel>>,
59 layered_directory_service:
63 directoryservice::combinators::Cache<DS, GRPCDirectoryService<Channel>>,
64
65 trusted_public_keys: Vec<narinfo::VerifyingKey>,
69
70 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 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 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 let narinfo = NarInfo::parse(&narinfo_str).map_err(Error::ParseNARInfo)?;
246
247 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_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 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 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 !resp.status().is_success() {
304 Err(Error::FailedToRequestNAR(resp.status()))?;
305 }
306
307 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 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 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 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 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 trusted_public_keys: Vec<String>,
434
435 #[serde(default)]
436 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[case::trusted_public_keys_no_sequence(
600 "nix+https://cache.nixos.org?trusted_public_keys=cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=",
601 None
602 )]
603 #[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}