Skip to main content

deps_gitlab_ci/
client.rs

1//! GitLab REST API client — fetches repository tags (`project:` includes) and project
2//! releases (`component:` includes) from a per-call, per-instance host.
3//!
4//! Parallel to, but not derived from, `deps_core::github::GithubTagsClient`: GitLab
5//! references may target a self-hosted instance (NFR-008), so the host is a per-call
6//! argument rather than a compile-time constant — the one structural difference driving
7//! every choice below.
8
9use 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
20/// GitLab's credential header — a distinct scheme from GitHub's `Authorization: Bearer`
21/// (NFR-006); this crate and `deps_core::github` deliberately do not share an auth-scheme
22/// abstraction for it (spec plan §1 "Ask First" item).
23fn private_token_header() -> HeaderName {
24    HeaderName::from_static("private-token")
25}
26
27/// Maximum number of pages fetched per (host, project, endpoint) combination.
28///
29/// Mirrors `deps_core::github::MAX_TAG_PAGES`'s role — a safety ceiling, not the
30/// correctness mechanism (`deps_core::pagination::page_has_more` already stops as soon as a
31/// page comes back partial). Kept at the same value since GitLab's `order_by=version`
32/// ordering (§4.2) does not have GitHub's lexicographic-ordering hazard that justified a
33/// generously high cap there, but there is no reason to pick a materially different number.
34pub const MAX_GITLAB_PAGES: u32 = 30;
35
36/// A `GITLAB_TOKEN` header value, redacted everywhere except the one call site that hands
37/// it to a request as a header value. Mirrors `deps_core::github`'s `AuthToken` (module-
38/// private there too — this crate keeps its own copy rather than widening that
39/// visibility for a ~15-line type).
40#[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
59/// A per-process random salt, mixed into [`own_auth_digest`] so the digest cannot be
60/// reconstructed offline from a known origin/token pair — mirrors
61/// `deps_nuget::registry::digest_salt`.
62fn 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
72/// A per-request auth identity for `HttpCache::get_cached_pinned_with_headers`'s `auth_id`
73/// argument — `None` when unauthenticated, otherwise a salted hash of `origin` and the
74/// token's header value. Ensures a response fetched without the token can never be served
75/// back to a request that would have carried it, or vice versa (spec §4.5's cache-key
76/// consequence of authenticating some hosts and not others for the same project).
77fn 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/// The actionable error returned when a request hits GitLab's rate limit, or a 401/403
87/// with no `GITLAB_TOKEN` configured (spec FR-014).
88#[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/// GitLab tags API response item (`GET /projects/:id/repository/tags`).
99#[derive(Debug, Default, Deserialize)]
100pub struct GitlabTag {
101    pub name: String,
102    #[serde(default)]
103    pub commit: GitlabCommit,
104}
105
106/// GitLab releases API response item (`GET /projects/:id/releases`).
107#[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/// The `commit` object nested in a [`GitlabTag`]/[`GitlabRelease`].
117#[derive(Debug, Default, Deserialize)]
118pub struct GitlabCommit {
119    #[serde(default)]
120    pub id: String,
121}
122
123/// GitLab API error response.
124#[derive(Deserialize)]
125struct GitlabErrorResponse {
126    #[serde(default)]
127    message: Option<serde_json::Value>,
128    #[serde(default)]
129    error: Option<String>,
130}
131
132/// Parses a single GitLab tags API response page.
133///
134/// # Errors
135///
136/// Returns [`DepsError::CacheError`] when `data` parses as a GitLab error object.
137pub fn parse_tags_page(data: &[u8]) -> Result<Vec<GitlabTag>> {
138    parse_gitlab_page(data)
139}
140
141/// Parses a single GitLab releases API response page.
142///
143/// # Errors
144///
145/// Returns [`DepsError::CacheError`] when `data` parses as a GitLab error object.
146pub 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/// Client for fetching repository tags and project releases from a per-call GitLab
169/// instance host.
170#[derive(Clone)]
171pub struct GitlabApiClient {
172    cache: Arc<HttpCache>,
173    token: Option<AuthToken>,
174    instance_host: Arc<GitlabInstanceHost>,
175    /// Origins already known (H3, #466 review) to reject `order_by=version` with a `400`
176    /// — a pre-16.0 self-hosted instance. Memoized per host so the degradation is
177    /// discovered once, not rediscovered (and repaid with a wasted round trip) on every
178    /// page of every subsequent fetch against that host.
179    degraded_order_by_hosts: Arc<DashSet<String>>,
180}
181
182impl GitlabApiClient {
183    /// Creates a new client backed by `cache`.
184    ///
185    /// Reads `GITLAB_TOKEN` from the environment for authenticated requests, sent as the
186    /// `PRIVATE-TOKEN` header — but only to the single token host (spec FR-005a, see
187    /// [`crate::host::token_host_origin`]).
188    #[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    /// Whether a `GITLAB_TOKEN` was present at construction.
206    #[must_use]
207    pub const fn has_token(&self) -> bool {
208        self.token.is_some()
209    }
210
211    /// Creates a client with `token` set directly, bypassing the environment — for tests
212    /// that need a deterministic token without mutating `std::env` (which is `unsafe` since
213    /// Rust 2024 and forbidden workspace-wide).
214    #[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    /// Fetches one page of `host`'s repository-tags API for `project_path`.
230    ///
231    /// Requests `order_by=version&sort=desc` (GitLab 16.0+) — `updated` ordering sorts by
232    /// *commit* date, so a backport tag cut from an old commit can fall past the page cap
233    /// (the same class of hazard `deps_core::github::MAX_TAG_PAGES`'s doc documents for
234    /// GitHub's lexicographic ordering). An older self-hosted instance answers an unknown
235    /// `order_by` with `400`; on such a `400`, for **any** page, this retries once with no
236    /// `order_by` parameter, logs the degradation at `debug`, and memoizes `host`'s origin in
237    /// `degraded_order_by_hosts` (H3, #466 review) so every later page of this fetch —
238    /// and every subsequent fetch against the same host — skips straight to the fallback URL
239    /// instead of re-discovering (and repaying the round trip for) the same `400`.
240    ///
241    /// # Errors
242    ///
243    /// Propagates the underlying HTTP/cache error unchanged.
244    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    /// Fetches one page of `host`'s project-releases API for `project_path`.
279    ///
280    /// No `order_by=version` here (GitLab's `/releases` has none) — its default ordering
281    /// is release-date descending, which is fine: `releases_to_versions` (this crate's own
282    /// releases-to-versions conversion step) re-sorts the parsed list newest-first by parsed
283    /// semver itself, and [`crate::component::resolve_component_pin`]'s FR-007 ladder
284    /// likewise selects by parsed semver rather than trusting fetch order — neither pass
285    /// depends on API ordering, and catalogs are small.
286    ///
287    /// # Errors
288    ///
289    /// Propagates the underlying HTTP/cache error unchanged.
290    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    /// Fetches `url` through the origin-pinned, connect-address-guarded `CacheTier::Pinned`
305    /// transport — the only sanctioned way to send a credential to a workspace-declared
306    /// host (issue #561/#562 precedent) — attaching `PRIVATE-TOKEN` only when `host` is the
307    /// single token host (spec FR-005a).
308    async fn fetch_pinned(&self, host: &GitlabHost, url: &str) -> Result<Bytes> {
309        // `is_some_and`, never `.unwrap_or(...)`: an invalid `registries.gitlab_instance_host`
310        // must disable the token outright (`token_host_origin` returns `None`), not silently
311        // fall back to comparing against a default that could coincidentally match `host`
312        // (security review, issue #466).
313        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    // --- parse_tags_page / parse_releases_page ---
352
353    #[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    // --- GitlabApiClient: token presence and host targeting ---
400
401    #[tokio::test]
402    async fn test_client_for_test_no_token_by_default_in_unit_tests() {
403        // `GITLAB_TOKEN` should not be relied upon in unit tests; this only asserts the
404        // constructor is usable without one.
405        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    /// Builds a [`GitlabHost`] pointed at a `mockito` server, bypassing
437    /// [`GitlabHost::parse`]'s https-only gate (tests need `http://127.0.0.1:PORT`).
438    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        // Anchored/substring regexes disambiguate the two requests without a `Matcher::Not`
446        // (not available in this mockito version): the first request's query contains
447        // `order_by=version` as a substring; the retry's query is *exactly*
448        // `per_page=100&page=1`.
449        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    // --- Token-host containment (spec FR-005a/§9.2 regression, security-relevant) ---
493
494    #[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        // `instance_host`'s raw-string path can't reach a `mockito` `127.0.0.1:PORT`
508        // host — it fails `GitlabHost::parse`'s port-rejecting validation, which is
509        // correct for production but unusable here — so this uses the test-only bypass
510        // that stores an already-constructed `GitlabHost` directly.
511        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        // instance_host configured for a DIFFERENT host than the mock server — the mock
535        // server's host is therefore never the token host.
536        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}