Skip to main content

auths_infra_http/namespace/
cargo_verifier.rs

1//! crates.io namespace verification adapter.
2
3use 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
16/// crates.io namespace ownership verifier.
17///
18/// Verifies crate ownership by cross-referencing the crates.io owners API
19/// with the user's verified GitHub platform claim.
20///
21/// Usage:
22/// ```ignore
23/// let verifier = CargoVerifier::new();
24/// let challenge = verifier.initiate(&package, &did, &platform).await?;
25/// let proof = verifier.verify(&package, &did, &platform, &challenge).await?;
26/// ```
27pub struct CargoVerifier {
28    client: reqwest::Client,
29    base_url: Url,
30}
31
32impl CargoVerifier {
33    /// Create a new verifier targeting the production crates.io API.
34    pub fn new() -> Self {
35        Self {
36            client: crate::default_http_client(),
37            // INVARIANT: hardcoded valid URL
38            #[allow(clippy::expect_used)]
39            base_url: Url::parse("https://crates.io").expect("valid URL"),
40        }
41    }
42
43    /// Create a verifier with a custom base URL (for testing).
44    ///
45    /// Args:
46    /// * `base_url`: The base URL to use instead of `https://crates.io`.
47    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        // INVARIANT: owners_url is built from a valid base_url
215        #[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
228/// Extract org name from team login format `github:org:team`.
229fn 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}