Skip to main content

spec_driven_docs/self_depend/
registry.rs

1//! Read the registry index, to learn which release is newest.
2//!
3//! This is the one network read this tool makes, and it reads one thing:
4//! the sparse index line per published version. Nothing is downloaded and
5//! nothing is written into a project, because moving a consumer's pin is a
6//! text edit and the version number is all it needs.
7//!
8//! The registry's own protocol decides the URL. The index path is derived
9//! from the crate name the way the protocol defines it, so no layout is
10//! hard-coded beyond the index root.
11
12use std::time::Duration;
13
14use semver::Version;
15use serde::Deserialize;
16
17use crate::error::AppError;
18
19/// The crate this tool distributes itself as.
20pub const CRATE_NAME: &str = "spec-driven-docs";
21
22/// The sparse index this tool reads.
23pub const INDEX_ROOT: &str = "https://index.crates.io";
24
25/// How long a connection may take to open.
26const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
27/// How long the response headers may take to arrive.
28const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
29/// How long one whole read may take.
30const TOTAL_TIMEOUT: Duration = Duration::from_secs(120);
31/// How many times a transient read is retried.
32const RETRY_BUDGET: u32 = 2;
33/// The longest a `Retry-After` is honoured.
34const MAX_RETRY_AFTER: Duration = Duration::from_secs(10);
35/// The largest index document this tool reads.
36const MAX_INDEX_BYTES: u64 = 16 * 1024 * 1024;
37
38/// One published version, as the sparse index states it.
39#[derive(Debug, Clone, Deserialize)]
40struct IndexEntry {
41    vers: String,
42    #[serde(default)]
43    yanked: bool,
44}
45
46/// The registry index, read over the network.
47#[derive(Debug, Clone)]
48pub struct Index {
49    root: String,
50    offline: bool,
51}
52
53impl Default for Index {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl Index {
60    /// The public index.
61    ///
62    /// The offline variable is the host saying the network is not there,
63    /// which is a stronger statement than a flag nobody passed. A read that
64    /// would otherwise fail slowly at a name it cannot resolve refuses at
65    /// once instead.
66    #[must_use]
67    pub fn new() -> Self {
68        Self {
69            root: INDEX_ROOT.to_string(),
70            offline: crate::domain::paths::variable(crate::domain::paths::OFFLINE_VAR).is_some(),
71        }
72    }
73
74    /// Refuse every read, for a caller that declared itself offline.
75    #[must_use]
76    pub const fn offline(mut self, offline: bool) -> Self {
77        self.offline = self.offline || offline;
78        self
79    }
80
81    /// Read another index root, for a test that serves its own.
82    #[must_use]
83    pub fn with_root(mut self, root: &str) -> Self {
84        self.root = root.trim_end_matches('/').to_string();
85        self
86    }
87
88    /// The newest stable release the registry serves.
89    ///
90    /// # Errors
91    ///
92    /// [`AppError::Refused`] when the caller is offline, when the index
93    /// cannot be read, or when it lists no stable version.
94    pub fn latest_version(&self) -> Result<Version, AppError> {
95        if self.offline {
96            return Err(AppError::Refused(
97                "offline: only the index says which release is newest".to_string(),
98            ));
99        }
100        let url = format!("{}/{}", self.root, index_path(CRATE_NAME));
101        let bytes = read(&url)?;
102        let text = String::from_utf8(bytes)
103            .map_err(|source| AppError::Refused(format!("{url} is not text: {source}")))?;
104        let mut entries = Vec::new();
105        for line in text.lines().filter(|line| !line.trim().is_empty()) {
106            let entry: IndexEntry = serde_json::from_str(line).map_err(|source| {
107                AppError::Refused(format!(
108                    "{url} carries a line this tool cannot read: {source}"
109                ))
110            })?;
111            entries.push(entry);
112        }
113        entries
114            .iter()
115            .filter(|entry| !entry.yanked)
116            .filter_map(|entry| entry.vers.parse::<Version>().ok())
117            .filter(|version| version.pre.is_empty())
118            .max()
119            .ok_or_else(|| AppError::Refused(format!("the registry serves no stable {CRATE_NAME}")))
120    }
121}
122
123/// One bounded read, retried where the answer says to.
124fn read(url: &str) -> Result<Vec<u8>, AppError> {
125    let agent: ureq::Agent = ureq::Agent::config_builder()
126        .timeout_connect(Some(CONNECT_TIMEOUT))
127        .timeout_recv_response(Some(RESPONSE_TIMEOUT))
128        .timeout_global(Some(TOTAL_TIMEOUT))
129        .user_agent(format!(
130            "sdd/{} (+{})",
131            env!("CARGO_PKG_VERSION"),
132            CRATE_NAME
133        ))
134        .build()
135        .into();
136    let mut attempt = 0;
137    loop {
138        match agent.get(url).call() {
139            Ok(mut response) => {
140                let status = response.status().as_u16();
141                if transient(status) && attempt < RETRY_BUDGET {
142                    std::thread::sleep(retry_after(&response));
143                    attempt += 1;
144                    continue;
145                }
146                if status != 200 {
147                    return Err(AppError::Refused(format!("{url} answered {status}")));
148                }
149                return response
150                    .body_mut()
151                    .with_config()
152                    .limit(MAX_INDEX_BYTES)
153                    .read_to_vec()
154                    .map_err(|source| {
155                        AppError::Refused(format!(
156                            "{url} did not read within {MAX_INDEX_BYTES} bytes: {source}"
157                        ))
158                    });
159            }
160            Err(source) if attempt < RETRY_BUDGET && is_transport(&source) => {
161                std::thread::sleep(Duration::from_millis(250));
162                attempt += 1;
163            }
164            Err(source) => {
165                return Err(AppError::Refused(format!(
166                    "{url} could not be read: {source}"
167                )));
168            }
169        }
170    }
171}
172
173/// The sparse index path for a crate name, as the protocol defines it.
174#[must_use]
175pub fn index_path(name: &str) -> String {
176    let lower = name.to_lowercase();
177    match lower.len() {
178        0 => lower,
179        1 => format!("1/{lower}"),
180        2 => format!("2/{lower}"),
181        3 => format!("3/{}/{lower}", &lower[..1]),
182        _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]),
183    }
184}
185
186/// Whether an answer is worth asking for again.
187const fn transient(status: u16) -> bool {
188    status == 429 || matches!(status, 500..=599)
189}
190
191/// Whether a failure was the transport rather than the answer.
192const fn is_transport(error: &ureq::Error) -> bool {
193    matches!(
194        error,
195        ureq::Error::Io(_) | ureq::Error::Timeout(_) | ureq::Error::ConnectionFailed
196    )
197}
198
199/// What the response asks a client to wait, bounded.
200fn retry_after(response: &ureq::http::Response<ureq::Body>) -> Duration {
201    response
202        .headers()
203        .get("retry-after")
204        .and_then(|value| value.to_str().ok())
205        .and_then(|value| value.trim().parse::<u64>().ok())
206        .map_or(Duration::from_millis(500), |seconds| {
207            Duration::from_secs(seconds).min(MAX_RETRY_AFTER)
208        })
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn the_index_path_follows_the_registry_protocol() {
217        assert_eq!(index_path("a"), "1/a");
218        assert_eq!(index_path("ab"), "2/ab");
219        assert_eq!(index_path("abc"), "3/a/abc");
220        assert_eq!(index_path(CRATE_NAME), "sp/ec/spec-driven-docs");
221    }
222
223    #[test]
224    fn an_offline_index_refuses_rather_than_guessing() {
225        let error = Index::new().offline(true).latest_version().unwrap_err();
226        assert_eq!(error.kind(), "Refused");
227        assert!(error.to_string().contains("newest"), "{error}");
228    }
229
230    #[test]
231    fn a_transient_answer_is_worth_asking_again() {
232        assert!(transient(429));
233        assert!(transient(503));
234        assert!(!transient(404));
235    }
236}