Skip to main content

uv_audit/service/
project_status.rs

1//! Auditing for [PEP 792] adverse project statuses.
2//!
3//! [PEP 792]: https://peps.python.org/pep-0792/
4
5use futures::{StreamExt as _, stream};
6use tokio::sync::Semaphore;
7use tracing::trace;
8
9use uv_client::{MetadataFormat, RegistryClient};
10use uv_configuration::Concurrency;
11use uv_distribution_types::{IndexCapabilities, IndexMetadataRef, IndexUrl};
12use uv_normalize::PackageName;
13use uv_pypi_types::{ProjectStatus as PypiProjectStatus, Status};
14
15use crate::types::{self, AdverseStatus, Finding};
16
17/// Audit projects for PEP 792 adverse status markers using a [`RegistryClient`].
18pub struct ProjectStatusAudit<'a> {
19    client: &'a RegistryClient,
20    capabilities: &'a IndexCapabilities,
21    concurrency: Concurrency,
22}
23
24impl<'a> ProjectStatusAudit<'a> {
25    /// Create a new audit session backed by the given [`RegistryClient`].
26    pub fn new(
27        client: &'a RegistryClient,
28        capabilities: &'a IndexCapabilities,
29        concurrency: Concurrency,
30    ) -> Self {
31        Self {
32            client,
33            capabilities,
34            concurrency,
35        }
36    }
37
38    /// Query the project-level status of each project on its index.
39    ///
40    /// Transient per-project query failures (network errors, not-found, offline
41    /// without a cache hit) are logged and dropped, on the principle that one
42    /// misbehaving index should not invalidate the rest of the audit.
43    pub async fn query_batch(&self, projects: &[(&PackageName, IndexUrl)]) -> Vec<Finding> {
44        if projects.is_empty() {
45            return Vec::new();
46        }
47
48        let semaphore = self.concurrency.downloads_semaphore.clone();
49
50        stream::iter(projects)
51            .map(|(name, index)| {
52                let semaphore = semaphore.clone();
53                async move { self.query_one(name, index, semaphore.as_ref()).await }
54            })
55            .buffer_unordered(self.concurrency.downloads)
56            .filter_map(|finding| async move { finding })
57            .collect()
58            .await
59    }
60
61    async fn query_one(
62        &self,
63        name: &PackageName,
64        index: &IndexUrl,
65        semaphore: &Semaphore,
66    ) -> Option<Finding> {
67        let results = match self
68            .client
69            .simple_detail(
70                name,
71                Some(IndexMetadataRef::from(index)),
72                self.capabilities,
73                semaphore,
74            )
75            .await
76        {
77            Ok(results) => results,
78            Err(err) => {
79                trace!("Skipping project-status check for `{name}`: {err}");
80                return None;
81            }
82        };
83
84        let archive = results
85            .into_iter()
86            .map(|(_, format)| match format {
87                MetadataFormat::Simple(archive) => archive,
88                MetadataFormat::Flat(_) => {
89                    unreachable!("Flat metadata should not be returned by `simple_detail`")
90                }
91            })
92            .next()?;
93
94        let project_status: PypiProjectStatus =
95            match rkyv::deserialize::<PypiProjectStatus, rkyv::rancor::Error>(
96                archive.project_status(),
97            ) {
98                Ok(project_status) => project_status,
99                Err(err) => {
100                    trace!("Failed to read archived project status for `{name}`: {err}");
101                    return None;
102                }
103            };
104
105        let status = to_adverse(project_status.status)?;
106        let reason = project_status.reason.map(|reason| reason.to_string());
107        Some(Finding::ProjectStatus(types::ProjectStatus {
108            name: name.clone(),
109            status,
110            reason,
111        }))
112    }
113}
114
115/// Map a PEP 792 [`Status`] to its [`AdverseStatus`] counterpart, if any.
116fn to_adverse(status: Status) -> Option<AdverseStatus> {
117    match status {
118        Status::Active => None,
119        Status::Archived => Some(AdverseStatus::Archived),
120        Status::Quarantined => Some(AdverseStatus::Quarantined),
121        Status::Deprecated => Some(AdverseStatus::Deprecated),
122    }
123}