From 2b8460cd76e9836d98db231546654d0c38c0da3d Mon Sep 17 00:00:00 2001 From: Wim Looman Date: Sat, 6 Apr 2024 19:51:14 +0200 Subject: [PATCH] Add support for downloading the sparse index config --- src/sparse.rs | 99 ++++++++++++++++++++++++++++++++------ tests/fixtures/config.json | 4 ++ tests/sparse_index/mod.rs | 72 +++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/config.json diff --git a/src/sparse.rs b/src/sparse.rs index bd5b35c4..fe8eda26 100644 --- a/src/sparse.rs +++ b/src/sparse.rs @@ -54,7 +54,8 @@ impl SparseIndex { Self { path, url } } - /// Get the global configuration of the index. + /// Get the global configuration of the index. There are no guarantees around freshness, + /// and if the config is not available, no fetch will be performed. pub fn index_config(&self) -> Result { let path = self.path.join("config.json"); let bytes = std::fs::read(path).map_err(Error::Io)?; @@ -137,24 +138,10 @@ impl SparseIndex { version } - /// Creates an HTTP request that can be sent via your HTTP client of choice - /// to retrieve the current metadata for the specified crate - /// - /// See [`Self::parse_cache_response`] processing the response from the remote - /// index - /// - /// It is highly recommended to assume HTTP/2 when making requests to remote - /// indices, at least crates.io #[cfg(feature = "sparse")] - pub fn make_cache_request(&self, name: &str) -> Result { + fn make_request(&self, url: &str, cache_version: Option<&str>) -> Result { use http::header; - let url = self - .crate_url(name) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "crate name is invalid"))?; - - let cache_version = self.read_cache_version(name); - let mut req = http::Request::get(url).version(http::Version::HTTP_2); { @@ -198,6 +185,86 @@ impl SparseIndex { Ok(req) } + /// Creates an HTTP request that can be sent via your HTTP client of choice + /// to retrieve the config for this index + /// + /// See [`Self::parse_config_response`] processing the response from the remote + /// index + /// + /// It is highly recommended to assume HTTP/2 when making requests to remote + /// indices, at least crates.io + #[cfg(feature = "sparse")] + pub fn make_config_request(&self) -> Result { + self.make_request(&format!("{}config.json", self.url()), None) + } + + /// Creates an HTTP request that can be sent via your HTTP client of choice + /// to retrieve the current metadata for the specified crate + /// + /// See [`Self::parse_cache_response`] processing the response from the remote + /// index + /// + /// It is highly recommended to assume HTTP/2 when making requests to remote + /// indices, at least crates.io + #[cfg(feature = "sparse")] + pub fn make_cache_request(&self, name: &str) -> Result { + self.make_request( + &self + .crate_url(name) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "crate name is invalid"))?, + self.read_cache_version(name).as_deref(), + ) + } + + /// Process the response to a request created by [`Self::make_config_request`] + /// + /// You may specify whether an updated config file is written locally to the + /// cache or not + /// + /// Note that responses from sparse HTTP indices, at least crates.io, may + /// send responses with `gzip` compression, it is your responsibility to + /// decompress it before sending to this function + #[cfg(feature = "sparse")] + pub fn parse_config_response( + &self, + response: http::Response>, + write_cache_entry: bool, + ) -> Result { + use http::StatusCode; + let (parts, body) = response.into_parts(); + + match parts.status { + // The server responded with the full contents of the config + StatusCode::OK => { + if write_cache_entry { + let path = self.path.join("config.json"); + if std::fs::create_dir_all(path.parent().unwrap()).is_ok() { + // It's unfortunate if this fails for some reason, but + // not writing the cache entry shouldn't stop the user + // from getting the config + let _ = std::fs::write(&path, &body); + } + } + + serde_json::from_slice(&body).map_err(Error::Json) + } + // The server requires authorization but the user didn't provide it + StatusCode::UNAUTHORIZED => { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "the request was not authorized").into()) + } + StatusCode::NOT_FOUND => { + Err(io::Error::new(io::ErrorKind::NotFound, "config.json not found in registry").into()) + } + other => Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "the server responded with status code '{other}', which is not supported in the current protocol" + ), + ) + .into()), + } + } + /// Process the response to a request created by [`Self::make_cache_request`] /// /// This handles both the scenario where the local cache is missing the specified diff --git a/tests/fixtures/config.json b/tests/fixtures/config.json new file mode 100644 index 00000000..afaa4915 --- /dev/null +++ b/tests/fixtures/config.json @@ -0,0 +1,4 @@ +{ + "dl": "https://static.crates.io/crates", + "api": "https://crates.io" +} diff --git a/tests/sparse_index/mod.rs b/tests/sparse_index/mod.rs index 1e9b73ed..ee15a999 100644 --- a/tests/sparse_index/mod.rs +++ b/tests/sparse_index/mod.rs @@ -124,4 +124,76 @@ mod with_sparse_http_feature { assert!(index.parse_cache_response("serde", response, false).unwrap().is_none()); } } + + mod make_config_request { + use crate::sparse_index::with_sparse_http_feature::crates_io; + use http::{header, Request}; + + #[test] + fn generates_request() { + let index = crates_io(); + let builder = index.make_config_request().unwrap(); + let req: Request> = builder.body(vec![]).unwrap(); + + assert_eq!(req.uri(), format!("{}config.json", index.url()).as_str()); + assert!(req.headers().get(header::IF_NONE_MATCH).is_none()); + assert!(req.headers().get(header::IF_MODIFIED_SINCE).is_none()); + assert_eq!(req.headers().get(header::ACCEPT_ENCODING).unwrap(), "gzip,identity"); + assert_eq!( + req.headers() + .get(header::HeaderName::from_static("cargo-protocol")) + .unwrap(), + "version=1" + ); + assert_eq!(req.headers().get(header::ACCEPT).unwrap(), "text/plain"); + } + } + + mod parse_config_response { + use crates_index::{Error, SparseIndex}; + use std::io; + + // curl -v -H 'accept-encoding: gzip,identity' https://index.crates.io/config.json + const CONFIG_JSON: &[u8] = include_bytes!("../../tests/fixtures/config.json"); + + fn crates_io_tmp() -> (tempfile::TempDir, SparseIndex) { + let dir = tempfile::tempdir().unwrap(); + let index = SparseIndex::with_path(dir.path(), crates_index::sparse::URL).unwrap(); + (dir, index) + } + + fn make_response() -> http::Response> { + http::Response::builder() + .status(http::StatusCode::OK) + .body(CONFIG_JSON.to_vec()) + .unwrap() + } + + #[test] + fn parses_response() { + let (_dir, index) = crates_io_tmp(); + + let config = index.parse_config_response(make_response(), false).unwrap(); + + assert_eq!(config.dl, "https://static.crates.io/crates"); + assert_eq!(config.api.as_deref(), Some("https://crates.io")); + } + + #[test] + fn stores_response() { + let (_dir, index) = crates_io_tmp(); + + let Err(Error::Io(err)) = index.index_config() else { + panic!("expected to get an io error") + }; + assert!(err.kind() == io::ErrorKind::NotFound); + + let config = index.parse_config_response(make_response(), true).unwrap(); + + let stored_config = index.index_config().unwrap(); + + assert_eq!(config.dl, stored_config.dl); + assert_eq!(config.api, stored_config.api); + } + } }