Skip to main content

ephemeris_release/adapters/cargo/
registry.rs

1//! Cargo registry version lookup.
2
3use std::ops::Range;
4
5use ephemeris_token::Token;
6use facet::Facet;
7use reqwest::Url;
8
9use crate::PackageRegistry;
10use crate::adapters::registry::{HttpRegistry, RegistryError};
11
12/// A Cargo registry version lookup.
13///
14/// The crates.io registry answers `api/v1/crates/{name}/{version}`. A named
15/// registry is read through its sparse index file for the crate, and a version
16/// counts as present when it is listed, yanked or not.
17///
18/// A request that fails to connect, answers 429, or answers a server error is
19/// retried up to four attempts with a doubling delay.
20///
21/// The token is sent as `Authorization: token <value>` and only to the
22/// configured registry host. Every request is built under the registry URL,
23/// and a redirect to another host drops the header.
24#[derive(Debug)]
25pub struct CargoRegistry {
26    http: HttpRegistry,
27    base: Url,
28    route: Route,
29}
30
31#[derive(Debug, Clone, Copy)]
32enum Route {
33    CratesIo,
34    SparseIndex,
35}
36
37#[derive(Facet)]
38struct IndexEntry {
39    vers: String,
40}
41
42impl CargoRegistry {
43    /// Look up versions under the crates.io API base `base`.
44    ///
45    /// A missing trailing slash on the path is added. Without a `token`,
46    /// requests are anonymous.
47    ///
48    /// # Errors
49    ///
50    /// Fails when the token is not a valid header value or the HTTP client
51    /// cannot start.
52    pub fn new(base: Url, token: Option<&Token>) -> Result<Self, RegistryError> {
53        Self::build(base, token, Route::CratesIo)
54    }
55
56    /// Look up versions in the sparse index rooted at `base`, as served by a
57    /// named registry.
58    ///
59    /// # Errors
60    ///
61    /// Fails when the token is not a valid header value or the HTTP client
62    /// cannot start.
63    pub fn sparse_index(base: Url, token: Option<&Token>) -> Result<Self, RegistryError> {
64        Self::build(base, token, Route::SparseIndex)
65    }
66
67    fn build(mut base: Url, token: Option<&Token>, route: Route) -> Result<Self, RegistryError> {
68        if !base.path().ends_with('/') {
69            base.set_path(&format!("{}/", base.path()));
70        }
71        Ok(Self {
72            http: HttpRegistry::new("token", token)?,
73            base,
74            route,
75        })
76    }
77
78    async fn get(&self, path: String) -> Result<Option<String>, RegistryError> {
79        let url = self
80            .base
81            .join(&path)
82            .map_err(|source| RegistryError::InvalidPath { path, source })?;
83        self.http.get(&url).await
84    }
85}
86
87impl PackageRegistry for CargoRegistry {
88    type Error = RegistryError;
89
90    async fn package_exists(&self, package: &str, version: &str) -> Result<bool, Self::Error> {
91        match self.route {
92            Route::CratesIo => Ok(self
93                .get(format!("api/v1/crates/{package}/{version}"))
94                .await?
95                .is_some()),
96            Route::SparseIndex => {
97                let Some(body) = self.get(index_path(package)).await? else {
98                    return Ok(false);
99                };
100                for line in body.lines().filter(|line| !line.trim().is_empty()) {
101                    let entry: IndexEntry =
102                        facet_json::from_str(line).map_err(RegistryError::Body)?;
103                    if entry.vers == version {
104                        return Ok(true);
105                    }
106                }
107                Ok(false)
108            }
109        }
110    }
111
112    fn base_url(&self) -> Option<&Url> {
113        Some(&self.base)
114    }
115}
116
117fn index_path(name: &str) -> String {
118    let name = name.to_ascii_lowercase();
119    let chars = |range: Range<usize>| {
120        name.chars()
121            .skip(range.start)
122            .take(range.len())
123            .collect::<String>()
124    };
125    match name.chars().count() {
126        len @ (0..=2) => format!("{len}/{name}"),
127        3 => format!("3/{}/{name}", chars(0..1)),
128        _ => format!("{}/{}/{name}", chars(0..2), chars(2..4)),
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use ephemeris_loopback::{Response, Server};
135    use ephemeris_token::Token;
136    use reqwest::Url;
137
138    use super::{CargoRegistry, RegistryError, index_path};
139    use crate::PackageRegistry as _;
140
141    const INDEX: &str = concat!(
142        "{\"name\":\"core\",\"vers\":\"1.0.0\",\"deps\":[],\"cksum\":\"x\",\"yanked\":false}\n",
143        "{\"name\":\"core\",\"vers\":\"1.1.0\",\"deps\":[],\"cksum\":\"x\",\"yanked\":true}\n",
144    );
145
146    #[tokio::test]
147    async fn lookup_goes_under_a_nested_registry_base_with_the_token() {
148        let server = Server::start(vec![Response::new(200, "{}")]);
149        let base = Url::parse(&format!("{}api/packages/acme/cargo", server.url())).unwrap();
150        let registry = CargoRegistry::new(base, Some(&Token::new("secret"))).unwrap();
151
152        assert!(registry.package_exists("core", "1.2.3").await.unwrap());
153
154        let requests = server.requests();
155        assert_eq!(requests.len(), 1);
156        assert_eq!(
157            requests[0].path,
158            "/api/packages/acme/cargo/api/v1/crates/core/1.2.3"
159        );
160        assert_eq!(requests[0].header("authorization"), Some("token secret"));
161    }
162
163    #[tokio::test]
164    async fn missing_version_is_absent_and_anonymous_without_a_token() {
165        let server = Server::start(vec![Response::new(404, "{}")]);
166        let registry = CargoRegistry::new(server.url(), None).unwrap();
167
168        assert!(!registry.package_exists("core", "1.2.3").await.unwrap());
169
170        let requests = server.requests();
171        assert_eq!(requests[0].path, "/api/v1/crates/core/1.2.3");
172        assert_eq!(requests[0].header("authorization"), None);
173    }
174
175    #[tokio::test]
176    async fn server_errors_are_retried() {
177        let server = Server::start(vec![Response::new(503, "busy"), Response::new(200, "{}")]);
178        let registry = CargoRegistry::new(server.url(), None).unwrap();
179
180        assert!(registry.package_exists("core", "1.2.3").await.unwrap());
181        assert_eq!(server.requests().len(), 2);
182    }
183
184    #[tokio::test]
185    async fn client_errors_carry_status_and_body_without_a_retry() {
186        let server = Server::start(vec![Response::new(400, "bad crate name")]);
187        let registry = CargoRegistry::new(server.url(), None).unwrap();
188
189        let error = registry.package_exists("core", "1.2.3").await.unwrap_err();
190
191        assert!(matches!(
192            &error,
193            RegistryError::Response { status: 400, body } if body == "bad crate name"
194        ));
195        assert_eq!(server.requests().len(), 1);
196    }
197
198    #[test]
199    fn index_path_follows_the_name_length() {
200        assert_eq!(index_path("a"), "1/a");
201        assert_eq!(index_path("ab"), "2/ab");
202        assert_eq!(index_path("abc"), "3/a/abc");
203        assert_eq!(index_path("abcd"), "ab/cd/abcd");
204        assert_eq!(index_path("Ql-oe"), "ql/-o/ql-oe");
205    }
206
207    #[tokio::test]
208    async fn index_lists_a_present_version() {
209        let server = Server::start(vec![Response::new(200, INDEX)]);
210        let base = Url::parse(&format!("{}api/packages/acme/cargo", server.url())).unwrap();
211        let registry = CargoRegistry::sparse_index(base, Some(&Token::new("secret"))).unwrap();
212
213        assert!(registry.package_exists("core", "1.0.0").await.unwrap());
214
215        let requests = server.requests();
216        assert_eq!(requests[0].path, "/api/packages/acme/cargo/co/re/core");
217        assert_eq!(requests[0].header("authorization"), Some("token secret"));
218    }
219
220    #[tokio::test]
221    async fn index_without_the_version_is_absent() {
222        let server = Server::start(vec![Response::new(200, INDEX)]);
223        let registry = CargoRegistry::sparse_index(server.url(), None).unwrap();
224
225        assert!(!registry.package_exists("core", "2.0.0").await.unwrap());
226    }
227
228    #[tokio::test]
229    async fn yanked_index_version_is_present() {
230        let server = Server::start(vec![Response::new(200, INDEX)]);
231        let registry = CargoRegistry::sparse_index(server.url(), None).unwrap();
232
233        assert!(registry.package_exists("core", "1.1.0").await.unwrap());
234    }
235
236    #[tokio::test]
237    async fn missing_index_is_absent() {
238        let server = Server::start(vec![Response::new(404, "")]);
239        let registry = CargoRegistry::sparse_index(server.url(), None).unwrap();
240
241        assert!(!registry.package_exists("core", "1.0.0").await.unwrap());
242    }
243}