1use bytes::Bytes;
10use dashmap::DashSet;
11use deps_core::cache::HttpCache;
12use deps_core::error::{DepsError, Result};
13use reqwest::header::HeaderName;
14use serde::Deserialize;
15use std::hash::{BuildHasher, Hash, Hasher};
16use std::sync::{Arc, OnceLock};
17
18use crate::host::{GitlabHost, GitlabInstanceHost, token_host_origin};
19
20fn private_token_header() -> HeaderName {
24 HeaderName::from_static("private-token")
25}
26
27pub const MAX_GITLAB_PAGES: u32 = 30;
35
36#[derive(Clone, PartialEq, Eq)]
41struct AuthToken(deps_core::secret::Redacted);
42
43impl AuthToken {
44 fn new(value: String) -> Self {
45 Self(deps_core::secret::Redacted::new(value))
46 }
47
48 fn expose_secret(&self) -> &str {
49 self.0.expose_secret()
50 }
51}
52
53impl std::fmt::Debug for AuthToken {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.write_str("AuthToken(***)")
56 }
57}
58
59fn digest_salt() -> u64 {
63 static SALT: OnceLock<u64> = OnceLock::new();
64 *SALT.get_or_init(|| {
65 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
66 std::process::id().hash(&mut hasher);
67 std::time::SystemTime::now().hash(&mut hasher);
68 hasher.finish()
69 })
70}
71
72fn own_auth_digest(origin: &str, token: Option<&str>) -> Option<u64> {
78 let token = token?;
79 let mut hasher = std::collections::hash_map::DefaultHasher::new();
80 digest_salt().hash(&mut hasher);
81 origin.hash(&mut hasher);
82 token.hash(&mut hasher);
83 Some(hasher.finish())
84}
85
86#[must_use]
89pub fn gitlab_rate_limit_error() -> DepsError {
90 DepsError::RateLimited {
91 message: "GitLab API rate limit exceeded or authentication required. Set \
92 GITLAB_TOKEN to a GitLab Personal/Project Access Token to increase the \
93 limit and access private projects."
94 .into(),
95 }
96}
97
98#[derive(Debug, Default, Deserialize)]
100pub struct GitlabTag {
101 pub name: String,
102 #[serde(default)]
103 pub commit: GitlabCommit,
104}
105
106#[derive(Debug, Default, Deserialize)]
108pub struct GitlabRelease {
109 pub tag_name: String,
110 #[serde(default)]
111 pub commit: GitlabCommit,
112 #[serde(default)]
113 pub released_at: Option<String>,
114}
115
116#[derive(Debug, Default, Deserialize)]
118pub struct GitlabCommit {
119 #[serde(default)]
120 pub id: String,
121}
122
123#[derive(Deserialize)]
125struct GitlabErrorResponse {
126 #[serde(default)]
127 message: Option<serde_json::Value>,
128 #[serde(default)]
129 error: Option<String>,
130}
131
132pub fn parse_tags_page(data: &[u8]) -> Result<Vec<GitlabTag>> {
138 parse_gitlab_page(data)
139}
140
141pub fn parse_releases_page(data: &[u8]) -> Result<Vec<GitlabRelease>> {
147 parse_gitlab_page(data)
148}
149
150fn parse_gitlab_page<T: serde::de::DeserializeOwned>(data: &[u8]) -> Result<Vec<T>> {
151 match deps_core::parser::parse_json_checked(data) {
152 Ok(items) => Ok(items),
153 Err(_) => {
154 if let Ok(err) = deps_core::parser::parse_json_checked::<GitlabErrorResponse>(data) {
155 let text = err
156 .message
157 .map(|v| v.to_string())
158 .or(err.error)
159 .unwrap_or_default();
160 Err(DepsError::CacheError(format!("GitLab API error: {text}")))
161 } else {
162 Ok(vec![])
163 }
164 }
165 }
166}
167
168#[derive(Clone)]
171pub struct GitlabApiClient {
172 cache: Arc<HttpCache>,
173 token: Option<AuthToken>,
174 instance_host: Arc<GitlabInstanceHost>,
175 degraded_order_by_hosts: Arc<DashSet<String>>,
180}
181
182impl GitlabApiClient {
183 #[must_use]
189 pub fn new(cache: Arc<HttpCache>, instance_host: Arc<GitlabInstanceHost>) -> Self {
190 let token = std::env::var("GITLAB_TOKEN")
191 .ok()
192 .map(zeroize::Zeroizing::new)
193 .filter(|t| !t.is_empty());
194 if token.is_some() {
195 tracing::info!("GITLAB_TOKEN detected, using authenticated GitLab API requests");
196 }
197 Self {
198 cache,
199 token: token.map(|t| AuthToken::new((*t).clone())),
200 instance_host,
201 degraded_order_by_hosts: Arc::new(DashSet::new()),
202 }
203 }
204
205 #[must_use]
207 pub const fn has_token(&self) -> bool {
208 self.token.is_some()
209 }
210
211 #[cfg(test)]
215 #[must_use]
216 fn for_test(
217 cache: Arc<HttpCache>,
218 instance_host: Arc<GitlabInstanceHost>,
219 token: Option<&str>,
220 ) -> Self {
221 Self {
222 cache,
223 token: token.map(|t| AuthToken::new(t.to_string())),
224 instance_host,
225 degraded_order_by_hosts: Arc::new(DashSet::new()),
226 }
227 }
228
229 pub async fn fetch_tags_page(
245 &self,
246 host: &GitlabHost,
247 project_path: &str,
248 page: u32,
249 ) -> Result<Bytes> {
250 let enc = urlencoding::encode(project_path);
251 let fallback_url = format!(
252 "{}/api/v4/projects/{enc}/repository/tags?per_page=100&page={page}",
253 host.origin()
254 );
255 if self.degraded_order_by_hosts.contains(host.origin()) {
256 return self.fetch_pinned(host, &fallback_url).await;
257 }
258 let url = format!(
259 "{}/api/v4/projects/{enc}/repository/tags?per_page=100&page={page}&order_by=version&sort=desc",
260 host.origin()
261 );
262 match self.fetch_pinned(host, &url).await {
263 Err(DepsError::HttpStatus { status: 400, .. }) => {
264 tracing::debug!(
265 host = host.host(),
266 page,
267 "GitLab instance rejected order_by=version; retrying without it and \
268 memoizing the degradation for this host"
269 );
270 self.degraded_order_by_hosts
271 .insert(host.origin().to_string());
272 self.fetch_pinned(host, &fallback_url).await
273 }
274 other => other,
275 }
276 }
277
278 pub async fn fetch_releases_page(
291 &self,
292 host: &GitlabHost,
293 project_path: &str,
294 page: u32,
295 ) -> Result<Bytes> {
296 let enc = urlencoding::encode(project_path);
297 let url = format!(
298 "{}/api/v4/projects/{enc}/releases?per_page=100&page={page}",
299 host.origin()
300 );
301 self.fetch_pinned(host, &url).await
302 }
303
304 async fn fetch_pinned(&self, host: &GitlabHost, url: &str) -> Result<Bytes> {
309 let is_token_host =
314 token_host_origin(&self.instance_host).is_some_and(|origin| origin == host.origin());
315 let token_value = if is_token_host {
316 self.token.as_ref().map(AuthToken::expose_secret)
317 } else {
318 None
319 };
320 let auth_id = own_auth_digest(host.origin(), token_value);
321 let headers: Vec<(HeaderName, &str)> = token_value
322 .map(|t| vec![(private_token_header(), t)])
323 .unwrap_or_default();
324
325 self.cache
326 .get_cached_pinned_with_headers(
327 url,
328 host.origin(),
329 token_value.is_some(),
330 auth_id,
331 &headers,
332 )
333 .await
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
341 use std::sync::RwLock;
342
343 fn instance_host(configured: Option<&str>) -> Arc<GitlabInstanceHost> {
344 let policy = Arc::new(RegistryAccessPolicy::new(WorkspaceRegistryAccess::All));
345 Arc::new(GitlabInstanceHost::new(
346 Arc::new(RwLock::new(configured.map(str::to_string))),
347 policy,
348 ))
349 }
350
351 #[test]
354 fn test_parse_tags_page_happy_path() {
355 let sha = "a".repeat(40);
356 let json = format!(r#"[{{"name":"v1.0.0","commit":{{"id":"{sha}"}}}}]"#);
357 let tags = parse_tags_page(json.as_bytes()).unwrap();
358 assert_eq!(tags.len(), 1);
359 assert_eq!(tags[0].name, "v1.0.0");
360 assert_eq!(tags[0].commit.id, sha);
361 }
362
363 #[test]
364 fn test_parse_releases_page_happy_path() {
365 let sha = "a".repeat(40);
366 let json = format!(
367 r#"[{{"tag_name":"1.0.0","commit":{{"id":"{sha}"}},"released_at":"2026-01-02T08:56:05Z"}}]"#
368 );
369 let releases = parse_releases_page(json.as_bytes()).unwrap();
370 assert_eq!(releases.len(), 1);
371 assert_eq!(releases[0].tag_name, "1.0.0");
372 assert_eq!(
373 releases[0].released_at.as_deref(),
374 Some("2026-01-02T08:56:05Z")
375 );
376 }
377
378 #[test]
379 fn test_parse_gitlab_page_error_object_returns_error() {
380 let json = r#"{"message":"404 Project Not Found"}"#;
381 let result: Result<Vec<GitlabTag>> = parse_gitlab_page(json.as_bytes());
382 assert!(result.is_err());
383 assert!(result.unwrap_err().to_string().contains("GitLab API error"));
384 }
385
386 #[test]
387 fn test_parse_gitlab_page_invalid_json_returns_empty() {
388 let result: Result<Vec<GitlabTag>> = parse_gitlab_page(b"not json");
389 assert!(result.unwrap().is_empty());
390 }
391
392 #[test]
393 fn test_parse_gitlab_page_missing_commit_defaults() {
394 let json = r#"[{"name":"1.0.0"}]"#;
395 let tags = parse_tags_page(json.as_bytes()).unwrap();
396 assert_eq!(tags[0].commit.id, "");
397 }
398
399 #[tokio::test]
402 async fn test_client_for_test_no_token_by_default_in_unit_tests() {
403 let client = GitlabApiClient::new(Arc::new(HttpCache::new()), instance_host(None));
406 let _ = client.has_token();
407 }
408
409 #[tokio::test]
410 async fn test_fetch_tags_page_wire_and_pagination() {
411 let mut server = mockito::Server::new_async().await;
412 let mock = server
413 .mock("GET", "/api/v4/projects/org%2Fproj/repository/tags")
414 .match_query(mockito::Matcher::AllOf(vec![
415 mockito::Matcher::UrlEncoded("order_by".into(), "version".into()),
416 mockito::Matcher::UrlEncoded("sort".into(), "desc".into()),
417 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
418 ]))
419 .with_status(200)
420 .with_body(r#"[{"name":"1.0.0","commit":{"id":"a"}}]"#)
421 .create_async()
422 .await;
423
424 let cache = Arc::new(HttpCache::new());
425 let client = GitlabApiClient::new(Arc::clone(&cache), instance_host(None));
426
427 let data = client
428 .fetch_tags_page(&test_host_for(&server.url()), "org/proj", 1)
429 .await
430 .unwrap();
431 let tags = parse_tags_page(&data).unwrap();
432 assert_eq!(tags.len(), 1);
433 mock.assert_async().await;
434 }
435
436 fn test_host_for(base_url: &str) -> GitlabHost {
439 GitlabHost::for_test(base_url)
440 }
441
442 #[tokio::test]
443 async fn test_fetch_tags_page_order_by_400_retries_without_it() {
444 let mut server = mockito::Server::new_async().await;
445 let _reject = server
450 .mock("GET", "/api/v4/projects/org%2Fproj/repository/tags")
451 .match_query(mockito::Matcher::Regex("order_by=version".into()))
452 .with_status(400)
453 .create_async()
454 .await;
455 let fallback = server
456 .mock("GET", "/api/v4/projects/org%2Fproj/repository/tags")
457 .match_query(mockito::Matcher::Regex("^per_page=100&page=1$".into()))
458 .with_status(200)
459 .with_body(r#"[{"name":"1.0.0","commit":{"id":"a"}}]"#)
460 .create_async()
461 .await;
462
463 let client = GitlabApiClient::new(Arc::new(HttpCache::new()), instance_host(None));
464 let data = client
465 .fetch_tags_page(&test_host_for(&server.url()), "org/proj", 1)
466 .await
467 .unwrap();
468 assert_eq!(parse_tags_page(&data).unwrap().len(), 1);
469 fallback.assert_async().await;
470 }
471
472 #[tokio::test]
473 async fn test_fetch_releases_page_wire() {
474 let mut server = mockito::Server::new_async().await;
475 let mock = server
476 .mock("GET", "/api/v4/projects/org%2Fproj/releases")
477 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
478 .with_status(200)
479 .with_body(r#"[{"tag_name":"1.0.0","commit":{"id":"a"}}]"#)
480 .create_async()
481 .await;
482
483 let client = GitlabApiClient::new(Arc::new(HttpCache::new()), instance_host(None));
484 let data = client
485 .fetch_releases_page(&test_host_for(&server.url()), "org/proj", 1)
486 .await
487 .unwrap();
488 assert_eq!(parse_releases_page(&data).unwrap().len(), 1);
489 mock.assert_async().await;
490 }
491
492 #[tokio::test]
495 async fn test_private_token_present_for_configured_token_host() {
496 let mut server = mockito::Server::new_async().await;
497 let mock = server
498 .mock("GET", "/api/v4/projects/org%2Fproj/repository/tags")
499 .match_query(mockito::Matcher::Any)
500 .match_header("private-token", "test-gitlab-token")
501 .with_status(200)
502 .with_body("[]")
503 .create_async()
504 .await;
505
506 let host = test_host_for(&server.url());
507 let instance = Arc::new(GitlabInstanceHost::for_test(host.clone()));
512 let client = GitlabApiClient::for_test(
513 Arc::new(HttpCache::new()),
514 instance,
515 Some("test-gitlab-token"),
516 );
517 client.fetch_tags_page(&host, "org/proj", 1).await.unwrap();
518 mock.assert_async().await;
519 }
520
521 #[tokio::test]
522 async fn test_private_token_absent_for_non_token_host() {
523 let mut server = mockito::Server::new_async().await;
524 let mock = server
525 .mock("GET", "/api/v4/projects/org%2Fproj/repository/tags")
526 .match_query(mockito::Matcher::Any)
527 .match_header("private-token", mockito::Matcher::Missing)
528 .with_status(200)
529 .with_body("[]")
530 .create_async()
531 .await;
532
533 let host = test_host_for(&server.url());
534 let instance = instance_host(Some("gitlab.other-instance.example"));
537 let client = GitlabApiClient::for_test(
538 Arc::new(HttpCache::new()),
539 instance,
540 Some("test-gitlab-token"),
541 );
542 client.fetch_tags_page(&host, "org/proj", 1).await.unwrap();
543 mock.assert_async().await;
544 }
545}