1use anyhow::{Context, Result};
28use async_trait::async_trait;
29use serde::Deserialize;
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, Clone, PartialEq)]
35pub struct RegistrySource {
36 pub index_url: String,
39 pub credential_provider: Option<String>,
40}
41
42pub fn resolve_registry_source(cargo_home: &Path) -> Option<RegistrySource> {
51 let text = std::fs::read_to_string(cargo_home.join("config.toml"))
52 .or_else(|_| std::fs::read_to_string(cargo_home.join("config")))
53 .ok()?;
54 let value: toml::Value = text.parse().ok()?;
55
56 let registry_name = value
57 .get("source")?
58 .get("crates-io")?
59 .get("replace-with")?
60 .as_str()?;
61 let registry = value.get("registries")?.get(registry_name)?;
62 let index = registry.get("index")?.as_str()?;
63 let index_url = format!("{}/", index.strip_prefix("sparse+")?.trim_end_matches('/'));
68 let credential_provider = registry
69 .get("credential-provider")
70 .and_then(|v| v.as_str())
71 .map(str::to_string);
72
73 Some(RegistrySource { index_url, credential_provider })
74}
75
76pub fn sparse_index_path(name: &str) -> String {
81 let name = name.to_lowercase();
82 match name.len() {
83 1 => format!("1/{name}"),
84 2 => format!("2/{name}"),
85 3 => format!("3/{}/{name}", &name[..1]),
86 _ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
87 }
88}
89
90#[derive(Deserialize)]
91struct IndexEntry {
92 vers: String,
93 #[serde(default)]
94 yanked: bool,
95}
96
97pub fn parse_latest_version(body: &str) -> Option<semver::Version> {
103 body.lines()
104 .filter_map(|line| serde_json::from_str::<IndexEntry>(line).ok())
105 .filter(|entry| !entry.yanked)
106 .filter_map(|entry| semver::Version::parse(&entry.vers).ok())
107 .max()
108}
109
110pub fn is_newer(current_version: &str, latest: &semver::Version) -> bool {
115 match semver::Version::parse(current_version) {
116 Ok(current) => *latest > current,
117 Err(e) => {
118 tracing::warn!("update check: couldn't parse current version {current_version:?}: {e}");
119 false
120 }
121 }
122}
123
124async fn mint_token(credential_provider: &str) -> Result<Option<String>> {
131 let Some(rest) = credential_provider.strip_prefix("cargo:token-from-stdout ") else {
132 tracing::warn!("update check: unsupported credential-provider scheme: {credential_provider}");
133 return Ok(None);
134 };
135 let mut parts = rest.split_whitespace();
136 let program = parts.next().context("empty credential-provider command")?;
137 let output = tokio::process::Command::new(program)
138 .args(parts)
139 .output()
140 .await
141 .with_context(|| format!("running credential-provider `{credential_provider}`"))?;
142 if !output.status.success() {
143 anyhow::bail!(
144 "credential-provider exited with {}: {}",
145 output.status,
146 String::from_utf8_lossy(&output.stderr).trim(),
147 );
148 }
149 Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string()))
150}
151
152#[async_trait]
156pub trait UpdateSource: Send + Sync {
157 async fn latest_version(&self, package: &str) -> Result<Option<semver::Version>>;
158}
159
160pub struct CargoRegistryUpdateSource;
165
166fn cargo_home() -> PathBuf {
167 std::env::var_os("CARGO_HOME")
168 .map(PathBuf::from)
169 .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
170 .unwrap_or_else(|| PathBuf::from(".cargo"))
171}
172
173#[async_trait]
174impl UpdateSource for CargoRegistryUpdateSource {
175 async fn latest_version(&self, package: &str) -> Result<Option<semver::Version>> {
176 let Some(source) = resolve_registry_source(&cargo_home()) else {
177 return Ok(None);
178 };
179 let token = match &source.credential_provider {
180 Some(provider) => mint_token(provider).await?,
181 None => None,
182 };
183
184 let url = format!("{}{}", source.index_url, sparse_index_path(package));
185 let mut request = reqwest::Client::new().get(&url);
186 if let Some(token) = token {
187 request = request.header(reqwest::header::AUTHORIZATION, token);
190 }
191 let response = request.send().await.context("fetching sparse index")?;
192 if !response.status().is_success() {
193 anyhow::bail!("sparse index request for {package} failed: {}", response.status());
194 }
195 let body = response.text().await.context("reading sparse index response")?;
196 Ok(parse_latest_version(&body))
197 }
198}
199
200pub async fn check_for_update(
205 source: &dyn UpdateSource,
206 package: &str,
207 current_version: &str,
208) -> Result<Option<semver::Version>> {
209 let Some(latest) = source.latest_version(package).await? else {
210 return Ok(None);
211 };
212 Ok(is_newer(current_version, &latest).then_some(latest))
213}
214
215#[async_trait]
218pub trait UpdateInstaller: Send + Sync {
219 async fn install(&self, package: &str) -> Result<()>;
220}
221
222pub struct CargoInstallInstaller;
228
229#[async_trait]
230impl UpdateInstaller for CargoInstallInstaller {
231 async fn install(&self, package: &str) -> Result<()> {
232 let output = tokio::process::Command::new("cargo")
233 .args(["install", package, "--force", "--locked"])
234 .output()
235 .await
236 .context("spawning cargo install")?;
237 if !output.status.success() {
238 anyhow::bail!(
239 "cargo install {package} exited with {}: {}",
240 output.status,
241 String::from_utf8_lossy(&output.stderr).trim(),
242 );
243 }
244 Ok(())
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn write_cargo_config(dir: &Path, contents: &str) {
253 std::fs::write(dir.join("config.toml"), contents).unwrap();
254 }
255
256 #[test]
257 fn resolve_registry_source_reads_replace_with_chain() {
258 let dir = tempfile::tempdir().unwrap();
259 write_cargo_config(dir.path(), r#"
260[registries.synthesia-cargo]
261index = "sparse+https://example.com/cargo/synthesia-cargo/"
262credential-provider = "cargo:token-from-stdout aws codeartifact get-authorization-token --domain d --domain-owner o --region r --query authorizationToken --output text"
263
264[source.crates-io]
265replace-with = "synthesia-cargo"
266"#);
267 let source = resolve_registry_source(dir.path()).expect("must resolve");
268 assert_eq!(source.index_url, "https://example.com/cargo/synthesia-cargo/");
269 assert_eq!(
270 source.credential_provider.as_deref(),
271 Some("cargo:token-from-stdout aws codeartifact get-authorization-token --domain d --domain-owner o --region r --query authorizationToken --output text"),
272 );
273 }
274
275 #[test]
276 fn resolve_registry_source_none_without_replace_with() {
277 let dir = tempfile::tempdir().unwrap();
278 write_cargo_config(dir.path(), r#"
279[registries.synthesia-cargo]
280index = "sparse+https://example.com/cargo/synthesia-cargo/"
281"#);
282 assert!(resolve_registry_source(dir.path()).is_none());
283 }
284
285 #[test]
286 fn resolve_registry_source_none_when_config_missing() {
287 let dir = tempfile::tempdir().unwrap();
288 assert!(resolve_registry_source(dir.path()).is_none());
289 }
290
291 #[test]
292 fn resolve_registry_source_none_when_registry_has_no_index() {
293 let dir = tempfile::tempdir().unwrap();
294 write_cargo_config(dir.path(), r#"
295[registries.synthesia-cargo]
296
297[source.crates-io]
298replace-with = "synthesia-cargo"
299"#);
300 assert!(resolve_registry_source(dir.path()).is_none());
301 }
302
303 #[test]
304 fn resolve_registry_source_none_for_a_non_sparse_index() {
305 let dir = tempfile::tempdir().unwrap();
306 write_cargo_config(dir.path(), r#"
307[registries.git-registry]
308index = "https://example.com/git-index.git"
309
310[source.crates-io]
311replace-with = "git-registry"
312"#);
313 assert!(resolve_registry_source(dir.path()).is_none());
314 }
315
316 #[test]
317 fn resolve_registry_source_credential_provider_optional() {
318 let dir = tempfile::tempdir().unwrap();
319 write_cargo_config(dir.path(), r#"
320[registries.public-mirror]
321index = "sparse+https://example.com/cargo/public-mirror/"
322
323[source.crates-io]
324replace-with = "public-mirror"
325"#);
326 let source = resolve_registry_source(dir.path()).expect("must resolve");
327 assert_eq!(source.credential_provider, None);
328 }
329
330 #[test]
331 fn sparse_index_path_matches_cargo_scheme() {
332 assert_eq!(sparse_index_path("a"), "1/a");
333 assert_eq!(sparse_index_path("ab"), "2/ab");
334 assert_eq!(sparse_index_path("abc"), "3/a/abc");
335 assert_eq!(sparse_index_path("ninox"), "ni/no/ninox");
336 assert_eq!(sparse_index_path("Ninox"), "ni/no/ninox");
337 }
338
339 #[test]
340 fn parse_latest_version_picks_highest_unyanked_semver() {
341 let body = "\
342{\"vers\":\"0.9.0\",\"yanked\":false}
343{\"vers\":\"0.13.0\",\"yanked\":false}
344{\"vers\":\"0.14.0\",\"yanked\":true}
345{\"vers\":\"0.10.0\",\"yanked\":false}
346";
347 assert_eq!(parse_latest_version(body), Some(semver::Version::new(0, 13, 0)));
348 }
349
350 #[test]
351 fn parse_latest_version_skips_malformed_lines() {
352 let body = "not json\n{\"vers\":\"1.0.0\",\"yanked\":false}\n{\"vers\":\"not-semver\",\"yanked\":false}\n";
353 assert_eq!(parse_latest_version(body), Some(semver::Version::new(1, 0, 0)));
354 }
355
356 #[test]
357 fn parse_latest_version_none_when_everything_yanked_or_empty() {
358 assert_eq!(parse_latest_version(""), None);
359 assert_eq!(parse_latest_version("{\"vers\":\"1.0.0\",\"yanked\":true}\n"), None);
360 }
361
362 #[test]
363 fn is_newer_true_when_latest_greater() {
364 assert!(is_newer("0.13.0", &semver::Version::new(0, 14, 0)));
365 }
366
367 #[test]
368 fn is_newer_false_when_equal_or_behind() {
369 assert!(!is_newer("0.13.0", &semver::Version::new(0, 13, 0)));
370 assert!(!is_newer("0.14.0", &semver::Version::new(0, 13, 0)));
371 }
372
373 #[test]
374 fn is_newer_false_when_current_unparsable() {
375 assert!(!is_newer("not-a-version", &semver::Version::new(0, 13, 0)));
376 }
377
378 struct FakeSource(Option<semver::Version>);
379
380 #[async_trait]
381 impl UpdateSource for FakeSource {
382 async fn latest_version(&self, _package: &str) -> Result<Option<semver::Version>> {
383 Ok(self.0.clone())
384 }
385 }
386
387 #[tokio::test]
388 async fn check_for_update_returns_newer_version() {
389 let source = FakeSource(Some(semver::Version::new(0, 14, 0)));
390 let result = check_for_update(&source, "ninox", "0.13.0").await.unwrap();
391 assert_eq!(result, Some(semver::Version::new(0, 14, 0)));
392 }
393
394 #[tokio::test]
395 async fn check_for_update_none_when_already_current() {
396 let source = FakeSource(Some(semver::Version::new(0, 13, 0)));
397 let result = check_for_update(&source, "ninox", "0.13.0").await.unwrap();
398 assert_eq!(result, None);
399 }
400
401 #[tokio::test]
402 async fn check_for_update_none_when_source_has_nothing() {
403 let source = FakeSource(None);
404 let result = check_for_update(&source, "ninox", "0.13.0").await.unwrap();
405 assert_eq!(result, None);
406 }
407}