uv_audit/service/
project_status.rs1use 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
17pub struct ProjectStatusAudit<'a> {
19 client: &'a RegistryClient,
20 capabilities: &'a IndexCapabilities,
21 concurrency: Concurrency,
22}
23
24impl<'a> ProjectStatusAudit<'a> {
25 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 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
115fn 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}