Skip to main content

pro_core/audit/
pypi.rs

1//! PyPI API client for yanked version detection
2//!
3//! Yanked releases on PyPI often indicate security issues or critical bugs.
4//! This module checks if installed packages are using yanked versions.
5
6use std::collections::HashMap;
7
8use serde::Deserialize;
9
10use crate::{Error, Result};
11
12/// PyPI JSON API base URL
13const PYPI_API_URL: &str = "https://pypi.org/pypi";
14
15/// PyPI API client
16pub struct PyPIClient {
17    client: reqwest::Client,
18}
19
20impl PyPIClient {
21    /// Create a new PyPI client
22    pub fn new() -> Self {
23        Self {
24            client: reqwest::Client::builder()
25                .user_agent("Pro/0.1.0")
26                .build()
27                .expect("Failed to create HTTP client"),
28        }
29    }
30
31    /// Check if a specific version of a package is yanked
32    pub async fn is_yanked(&self, package: &str, version: &str) -> Result<bool> {
33        let url = format!("{}/{}/json", PYPI_API_URL, package);
34
35        let response = self.client.get(&url).send().await.map_err(Error::Network)?;
36
37        if !response.status().is_success() {
38            // Package not found or API error - assume not yanked
39            return Ok(false);
40        }
41
42        let pkg_info: PyPIPackageInfo = response.json().await.map_err(Error::Network)?;
43
44        // Check if the specific version is yanked
45        if let Some(releases) = pkg_info.releases.get(version) {
46            // A version is yanked if ALL its files are yanked
47            // (empty releases are considered not yanked)
48            if releases.is_empty() {
49                return Ok(false);
50            }
51            return Ok(releases.iter().all(|r| r.yanked));
52        }
53
54        Ok(false)
55    }
56
57    /// Check multiple packages for yanked versions
58    pub async fn check_yanked_batch(
59        &self,
60        packages: &[(&str, &str)], // (name, version)
61    ) -> Result<Vec<YankedPackage>> {
62        let mut yanked = Vec::new();
63
64        // Check packages in parallel (limited concurrency)
65        let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(8));
66        let mut handles = Vec::new();
67
68        for (name, version) in packages {
69            let name = name.to_string();
70            let version = version.to_string();
71            let client = self.client.clone();
72            let semaphore = semaphore.clone();
73
74            let handle = tokio::spawn(async move {
75                let _permit = semaphore.acquire().await.unwrap();
76                check_single_package(&client, &name, &version).await
77            });
78            handles.push(handle);
79        }
80
81        for handle in handles {
82            if let Ok(Ok(Some(yanked_pkg))) = handle.await {
83                yanked.push(yanked_pkg);
84            }
85        }
86
87        Ok(yanked)
88    }
89}
90
91impl Default for PyPIClient {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97/// Check a single package for yanked status
98async fn check_single_package(
99    client: &reqwest::Client,
100    package: &str,
101    version: &str,
102) -> Result<Option<YankedPackage>> {
103    let url = format!("{}/{}/json", PYPI_API_URL, package);
104
105    let response = client.get(&url).send().await.map_err(Error::Network)?;
106
107    if !response.status().is_success() {
108        return Ok(None);
109    }
110
111    let pkg_info: PyPIPackageInfo = response.json().await.map_err(Error::Network)?;
112
113    if let Some(releases) = pkg_info.releases.get(version) {
114        if !releases.is_empty() && releases.iter().all(|r| r.yanked) {
115            // Get yanked reason from first file
116            let reason = releases.first().and_then(|r| r.yanked_reason.clone());
117            return Ok(Some(YankedPackage {
118                name: package.to_string(),
119                version: version.to_string(),
120                reason,
121            }));
122        }
123    }
124
125    Ok(None)
126}
127
128/// A package version that has been yanked from PyPI
129#[derive(Debug, Clone)]
130pub struct YankedPackage {
131    /// Package name
132    pub name: String,
133    /// Yanked version
134    pub version: String,
135    /// Reason for yanking (if provided)
136    pub reason: Option<String>,
137}
138
139// PyPI API response types
140
141#[derive(Debug, Deserialize)]
142struct PyPIPackageInfo {
143    releases: HashMap<String, Vec<PyPIRelease>>,
144}
145
146#[derive(Debug, Deserialize)]
147struct PyPIRelease {
148    yanked: bool,
149    yanked_reason: Option<String>,
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[tokio::test]
157    #[ignore] // Requires network
158    async fn test_check_not_yanked() {
159        let client = PyPIClient::new();
160        // requests 2.31.0 should not be yanked
161        let yanked = client.is_yanked("requests", "2.31.0").await.unwrap();
162        assert!(!yanked);
163    }
164}