auths_infra_http/namespace/
cargo_verifier.rs1use async_trait::async_trait;
4use chrono::{DateTime, Duration, Utc};
5use serde::Deserialize;
6use url::Url;
7
8use auths_core::ports::namespace::{
9 Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName,
10 PlatformContext, VerificationChallenge, VerificationMethod,
11};
12use auths_verifier::CanonicalDid;
13
14use super::generate_verification_token;
15
16pub struct CargoVerifier {
28 client: reqwest::Client,
29 base_url: Url,
30}
31
32impl CargoVerifier {
33 pub fn new() -> Self {
35 Self {
36 client: crate::default_http_client(),
37 #[allow(clippy::expect_used)]
39 base_url: Url::parse("https://crates.io").expect("valid URL"),
40 }
41 }
42
43 pub fn with_base_url(base_url: Url) -> Self {
48 Self {
49 client: crate::default_http_client(),
50 base_url,
51 }
52 }
53
54 fn owners_url(&self, crate_name: &str) -> String {
55 format!("{}/api/v1/crates/{}/owners", self.base_url, crate_name)
56 }
57
58 fn crate_url(&self, crate_name: &str) -> String {
59 format!("{}/api/v1/crates/{}", self.base_url, crate_name)
60 }
61
62 async fn fetch_crate_exists(&self, crate_name: &str) -> Result<(), NamespaceVerifyError> {
63 let url = self.crate_url(crate_name);
64 let resp =
65 self.client
66 .get(&url)
67 .send()
68 .await
69 .map_err(|e| NamespaceVerifyError::NetworkError {
70 message: e.to_string(),
71 })?;
72
73 match resp.status().as_u16() {
74 200 => Ok(()),
75 404 => Err(NamespaceVerifyError::PackageNotFound {
76 ecosystem: Ecosystem::Cargo,
77 package_name: crate_name.to_string(),
78 }),
79 429 => Err(NamespaceVerifyError::RateLimited {
80 ecosystem: Ecosystem::Cargo,
81 }),
82 status => Err(NamespaceVerifyError::NetworkError {
83 message: format!("crates.io returned HTTP {status}"),
84 }),
85 }
86 }
87
88 async fn fetch_owners(
89 &self,
90 crate_name: &str,
91 ) -> Result<Vec<CratesIoOwner>, NamespaceVerifyError> {
92 let url = self.owners_url(crate_name);
93 let resp =
94 self.client
95 .get(&url)
96 .send()
97 .await
98 .map_err(|e| NamespaceVerifyError::NetworkError {
99 message: e.to_string(),
100 })?;
101
102 match resp.status().as_u16() {
103 200 => {}
104 404 => {
105 return Err(NamespaceVerifyError::PackageNotFound {
106 ecosystem: Ecosystem::Cargo,
107 package_name: crate_name.to_string(),
108 });
109 }
110 429 => {
111 return Err(NamespaceVerifyError::RateLimited {
112 ecosystem: Ecosystem::Cargo,
113 });
114 }
115 status => {
116 return Err(NamespaceVerifyError::NetworkError {
117 message: format!("crates.io owners API returned HTTP {status}"),
118 });
119 }
120 }
121
122 let body: CratesIoOwnersResponse =
123 resp.json()
124 .await
125 .map_err(|e| NamespaceVerifyError::NetworkError {
126 message: format!("failed to parse crates.io owners response: {e}"),
127 })?;
128
129 Ok(body.users)
130 }
131}
132
133impl Default for CargoVerifier {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139#[async_trait]
140impl NamespaceVerifier for CargoVerifier {
141 fn ecosystem(&self) -> Ecosystem {
142 Ecosystem::Cargo
143 }
144
145 async fn initiate(
146 &self,
147 now: DateTime<Utc>,
148 package_name: &PackageName,
149 did: &CanonicalDid,
150 platform: &PlatformContext,
151 ) -> Result<VerificationChallenge, NamespaceVerifyError> {
152 self.fetch_crate_exists(package_name.as_str()).await?;
153
154 let github_username = platform.github_username.as_deref().ok_or_else(|| {
155 NamespaceVerifyError::OwnershipNotConfirmed {
156 ecosystem: Ecosystem::Cargo,
157 package_name: package_name.as_str().to_string(),
158 }
159 })?;
160
161 let token = generate_verification_token();
162 let expires_at = now + Duration::hours(1);
163
164 Ok(VerificationChallenge {
165 ecosystem: Ecosystem::Cargo,
166 package_name: package_name.clone(),
167 did: did.clone(),
168 token,
169 instructions: format!(
170 "Verify your GitHub account ({github_username}) is listed as an owner \
171 of crate '{}' on crates.io",
172 package_name.as_str()
173 ),
174 expires_at,
175 })
176 }
177
178 async fn verify(
179 &self,
180 now: DateTime<Utc>,
181 package_name: &PackageName,
182 _did: &CanonicalDid,
183 platform: &PlatformContext,
184 _challenge: &VerificationChallenge,
185 ) -> Result<NamespaceOwnershipProof, NamespaceVerifyError> {
186 let github_username = platform.github_username.as_deref().ok_or_else(|| {
187 NamespaceVerifyError::OwnershipNotConfirmed {
188 ecosystem: Ecosystem::Cargo,
189 package_name: package_name.as_str().to_string(),
190 }
191 })?;
192
193 let owners = self.fetch_owners(package_name.as_str()).await?;
194
195 let is_owner = owners.iter().any(|owner| {
196 if owner.kind == "user" {
197 owner.login.eq_ignore_ascii_case(github_username)
198 } else if owner.kind == "team" {
199 extract_team_org(&owner.login)
200 .is_some_and(|org| github_username.eq_ignore_ascii_case(org))
201 } else {
202 false
203 }
204 });
205
206 if !is_owner {
207 return Err(NamespaceVerifyError::OwnershipNotConfirmed {
208 ecosystem: Ecosystem::Cargo,
209 package_name: package_name.as_str().to_string(),
210 });
211 }
212
213 let owners_url = self.owners_url(package_name.as_str());
214 #[allow(clippy::expect_used)]
216 let proof_url = Url::parse(&owners_url).expect("owners URL is valid");
217
218 Ok(NamespaceOwnershipProof {
219 ecosystem: Ecosystem::Cargo,
220 package_name: package_name.clone(),
221 proof_url,
222 method: VerificationMethod::ApiOwnership,
223 verified_at: now,
224 })
225 }
226}
227
228fn extract_team_org(login: &str) -> Option<&str> {
230 let parts: Vec<&str> = login.split(':').collect();
231 if parts.len() >= 2 && parts[0] == "github" {
232 Some(parts[1])
233 } else {
234 None
235 }
236}
237
238#[derive(Debug, Deserialize)]
239struct CratesIoOwnersResponse {
240 users: Vec<CratesIoOwner>,
241}
242
243#[derive(Debug, Deserialize)]
244struct CratesIoOwner {
245 login: String,
246 kind: String,
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn extract_team_org_from_team_login() {
255 assert_eq!(
256 extract_team_org("github:serde-rs:publish"),
257 Some("serde-rs")
258 );
259 assert_eq!(extract_team_org("github:myorg:team"), Some("myorg"));
260 }
261
262 #[test]
263 fn extract_team_org_returns_none_for_user_login() {
264 assert_eq!(extract_team_org("dtolnay"), None);
265 assert_eq!(extract_team_org(""), None);
266 }
267
268 #[test]
269 fn owner_matching_case_insensitive() {
270 let owners = [
271 CratesIoOwner {
272 login: "DTolnay".to_string(),
273 kind: "user".to_string(),
274 },
275 CratesIoOwner {
276 login: "github:serde-rs:publish".to_string(),
277 kind: "team".to_string(),
278 },
279 ];
280
281 let found_user = owners
282 .iter()
283 .any(|o| o.kind == "user" && o.login.eq_ignore_ascii_case("dtolnay"));
284 assert!(found_user);
285
286 let found_team = owners.iter().any(|o| {
287 o.kind == "team"
288 && extract_team_org(&o.login)
289 .is_some_and(|org| "serde-rs".eq_ignore_ascii_case(org))
290 });
291 assert!(found_team);
292 }
293
294 #[test]
295 fn owner_not_found_in_list() {
296 let owners = [CratesIoOwner {
297 login: "someone-else".to_string(),
298 kind: "user".to_string(),
299 }];
300
301 let found = owners
302 .iter()
303 .any(|o| o.kind == "user" && o.login.eq_ignore_ascii_case("myuser"));
304 assert!(!found);
305 }
306
307 #[test]
308 fn parse_owners_response() {
309 let json = r#"{"users":[{"id":3618,"login":"dtolnay","kind":"user","url":"https://github.com/dtolnay","name":"David Tolnay"},{"id":8138,"login":"github:serde-rs:publish","kind":"team","url":"https://github.com/serde-rs"}]}"#;
310 let resp: CratesIoOwnersResponse = serde_json::from_str(json).unwrap();
311 assert_eq!(resp.users.len(), 2);
312 assert_eq!(resp.users[0].login, "dtolnay");
313 assert_eq!(resp.users[0].kind, "user");
314 assert_eq!(resp.users[1].login, "github:serde-rs:publish");
315 assert_eq!(resp.users[1].kind, "team");
316 }
317}