release_utils/
crate_registry.rs

1// Copyright 2024 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use crate::cmd::{
10    format_cmd, get_cmd_stdout_utf8, wait_for_child, RunCommandError,
11};
12use std::fmt::{self, Display, Formatter};
13use std::io::Read;
14use std::process::{Child, Command, Stdio};
15
16/// Error returned by [`CrateRegistry::get_crate_versions`].
17#[derive(Debug)]
18pub enum GetCrateVersionsError {
19    /// The crate has not yet been published.
20    NotPublished,
21
22    /// An internal error occurred.
23    Internal {
24        /// Description of the internal error.
25        msg: String,
26
27        /// Optional underlying error.
28        cause: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
29    },
30}
31
32impl Display for GetCrateVersionsError {
33    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
34        write!(f, "failed to get crate versions: ")?;
35        match self {
36            Self::NotPublished => write!(f, "crate has not yet been published"),
37            Self::Internal { msg, .. } => {
38                write!(f, "{msg}")
39            }
40        }
41    }
42}
43
44impl std::error::Error for GetCrateVersionsError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            Self::NotPublished => None,
48            Self::Internal { cause, .. } => cause.as_ref().map(|err| {
49                // TODO: for some reason this extra cast is needed to
50                // drop the Send/Sync bounds.
51                let err: &(dyn std::error::Error + 'static) = &**err;
52                err
53            }),
54        }
55    }
56}
57
58/// Access a crate registry.
59pub struct CrateRegistry {
60    /// Base URL of the sparse registry.
61    pub registry_url: String,
62}
63
64impl CrateRegistry {
65    /// URL for the crates.io registry.
66    pub const DEFAULT_REGISTRY: &'static str = "https://index.crates.io";
67
68    /// Create a new `CrateRegistry` with the default registry.
69    pub fn new() -> Self {
70        Self {
71            registry_url: Self::DEFAULT_REGISTRY.to_string(),
72        }
73    }
74
75    /// Get the URL of the crate in the registry.
76    fn get_crate_url(&self, crate_name: &str) -> String {
77        assert!(!crate_name.is_empty());
78
79        let mut url = self.registry_url.clone();
80        if !url.ends_with('/') {
81            url.push('/');
82        }
83
84        // https://doc.rust-lang.org/cargo/reference/registry-index.html#index-files
85        if crate_name.len() == 1 {
86            url.push_str(&format!("1/{crate_name}"));
87        } else if crate_name.len() == 2 {
88            url.push_str(&format!("2/{crate_name}"));
89        } else if crate_name.len() == 3 {
90            url.push_str(&format!("3/{}/{}", &crate_name[0..1], crate_name));
91        } else {
92            url.push_str(&format!(
93                "{}/{}/{}",
94                &crate_name[..2],
95                &crate_name[2..4],
96                crate_name
97            ));
98        }
99
100        url
101    }
102
103    /// Get all published versions of a crate.
104    ///
105    /// If the crate has not yet been published,
106    /// [`GetCrateVersionsError::NotPublished`] is returned.
107    pub fn get_crate_versions(
108        &self,
109        crate_name: &str,
110    ) -> Result<Vec<String>, GetCrateVersionsError> {
111        let (mut curl_proc, curl_cmd_str) = spawn_curl(self, crate_name)
112            .map_err(|err| GetCrateVersionsError::Internal {
113                msg: "failed to launch curl".to_string(),
114                cause: Some(Box::new(err)),
115            })?;
116
117        // OK to unwrap, we know stderr and stdout are set.
118        let mut curl_stderr_pipe = curl_proc.stderr.take().unwrap();
119        let curl_stdout_pipe = curl_proc.stdout.take().unwrap();
120
121        let versions_result = parse_versions_from_crate_json(curl_stdout_pipe);
122
123        wait_for_child(curl_proc, curl_cmd_str).map_err(|err| {
124            GetCrateVersionsError::Internal {
125                msg: "curl failed".to_string(),
126                cause: Some(Box::new(err)),
127            }
128        })?;
129
130        let mut stderr_bytes = Vec::new();
131        curl_stderr_pipe
132            .read_to_end(&mut stderr_bytes)
133            .map_err(|err| GetCrateVersionsError::Internal {
134                msg: "failed to read http code from curl".to_string(),
135                cause: Some(Box::new(err)),
136            })?;
137
138        let stderr = String::from_utf8(stderr_bytes).map_err(|err| {
139            GetCrateVersionsError::Internal {
140                msg: "curl http code is not utf-8".to_string(),
141                cause: Some(Box::new(err)),
142            }
143        })?;
144
145        let code: i32 = stderr.trim().parse().map_err(|_| {
146            GetCrateVersionsError::Internal {
147                msg: format!("invalid HTTP code: {stderr:?}"),
148                cause: None,
149            }
150        })?;
151        if code == 404 {
152            return Err(GetCrateVersionsError::NotPublished);
153        }
154        if code != 200 {
155            return Err(GetCrateVersionsError::Internal {
156                msg: format!("invalid HTTP code: {code}"),
157                cause: None,
158            });
159        }
160
161        versions_result.map_err(|err| GetCrateVersionsError::Internal {
162            msg: "jq failed".to_string(),
163            cause: Some(Box::new(err)),
164        })
165    }
166}
167
168impl Default for CrateRegistry {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174fn spawn_curl(
175    registry: &CrateRegistry,
176    crate_name: &str,
177) -> Result<(Child, String), RunCommandError> {
178    let mut cmd = Command::new("curl");
179    // Turn off progress output.
180    cmd.args(["--silent"]);
181    // Write the HTTP status code to stderr.
182    cmd.args(["--write-out", "%{stderr}%{http_code}"]);
183    // Fetch the crate's JSON file from the index.
184    cmd.arg(registry.get_crate_url(crate_name));
185    // Capture both stdout and stderr.
186    cmd.stderr(Stdio::piped());
187    cmd.stdout(Stdio::piped());
188    let curl_cmd_str = format_cmd(&cmd);
189    let child = cmd.spawn().map_err(|err| RunCommandError::Launch {
190        cmd: curl_cmd_str.clone(),
191        err,
192    })?;
193    Ok((child, curl_cmd_str))
194}
195
196fn parse_versions_from_crate_json(
197    input: impl Into<Stdio>,
198) -> Result<Vec<String>, RunCommandError> {
199    let mut cmd = Command::new("jq");
200    // Remove quotes.
201    cmd.arg("--raw-output");
202    // Select the version field.
203    cmd.arg(".vers");
204    cmd.stdin(input);
205    let output = get_cmd_stdout_utf8(cmd)?;
206
207    Ok(output.lines().map(|l| l.to_string()).collect())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::fs::{self, File};
214    use tempfile::tempdir;
215
216    #[test]
217    fn test_url() {
218        let cargo = CrateRegistry::new();
219
220        assert_eq!(cargo.get_crate_url("a"), "https://index.crates.io/1/a");
221
222        assert_eq!(cargo.get_crate_url("aa"), "https://index.crates.io/2/aa");
223
224        assert_eq!(
225            cargo.get_crate_url("aaa"),
226            "https://index.crates.io/3/a/aaa"
227        );
228
229        assert_eq!(
230            cargo.get_crate_url("release-utils"),
231            "https://index.crates.io/re/le/release-utils"
232        );
233    }
234
235    #[test]
236    fn test_jq() {
237        let tmp_dir = tempdir().unwrap();
238        let path = tmp_dir.path().join("crate.json");
239        fs::write(&path, r#"{"name":"release-utils","vers":"0.2.4","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"92959b131c3d34846e39fed70bd7504684df0c6937ae736860329bd67836922e","features":{},"yanked":false,"rust_version":"1.70"}
240{"name":"release-utils","vers":"0.3.0","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"tempfile","req":"^3.9.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"dev"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"ce9721f93fd5cc4aa5cb82e9e550af437c55adfc49731984185e691442a932f9","features":{},"yanked":false,"rust_version":"1.70"}
241{"name":"release-utils","vers":"0.4.0","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"tempfile","req":"^3.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"dev"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"0aa93a5aaaed004e0222a3207cf5ec5dc15a39baea0e412bebfb7aa7bb8fa14c","features":{},"yanked":false,"rust_version":"1.70"}
242{"name":"release-utils","vers":"0.4.1","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"tempfile","req":"^3.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"dev"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"02922e087d9f1da9f783ca54f4621f1a156ffc3f8563d66c2d74b5d2d6363ccf","features":{},"yanked":false,"rust_version":"1.70"}
243"#).unwrap();
244        let file = File::open(path).unwrap();
245        let versions = parse_versions_from_crate_json(file).unwrap();
246        assert_eq!(versions, ["0.2.4", "0.3.0", "0.4.0", "0.4.1"]);
247    }
248}