Skip to main content

deps_gitlab_ci/
host.rs

1//! GitLab instance host validation and resolution.
2//!
3//! Two related concerns live here (spec FR-005a/FR-011a, plan §4.1/§4.5):
4//!
5//! - [`GitlabHost`] — a validated, policy-gated host newtype, produced once per unique host
6//!   string encountered in a manifest (a `component:` prefix) or read from configuration.
7//! - [`GitlabInstanceHost`] — the live-updatable `registries.gitlab_instance_host` setting,
8//!   which is both the host `project:` includes resolve against when set, and the *only*
9//!   host `GITLAB_TOKEN` may ever be attached to (replacing, not extending, `gitlab.com`).
10
11use deps_core::net_policy::{
12    IndexUrlError, PolicyGate, RegistryAccessPolicy, WorkspaceRegistryAccess, validate_index_url,
13};
14use std::sync::{Arc, RwLock};
15
16/// The default GitLab.com host — the token host when `registries.gitlab_instance_host` is
17/// unset (FR-005a).
18pub const GITLAB_COM: &str = "gitlab.com";
19
20/// `GITLAB_COM`'s normalized, ASCII-serialized origin — the value every token-host
21/// comparison runs against for the default (unconfigured) case.
22pub const GITLAB_COM_ORIGIN: &str = "https://gitlab.com";
23
24/// A validated GitLab instance host.
25///
26/// `https`-only, no userinfo, not a loopback/link-local/private/cloud-metadata address (per
27/// the live [`RegistryAccessPolicy`]), and round-tripped through URL parsing so a
28/// structurally-injected value (`gitlab.com?x`, `gitlab.com/x`) cannot smuggle extra URL
29/// components past validation.
30///
31/// Both the verified host and its ASCII-serialized origin are computed once at construction
32/// — the origin is needed repeatedly (the token-host comparison, the pinned-transport
33/// `trusted_origin` argument, and the `auth_id` digest), and re-deriving it per call is how
34/// normalization bugs enter.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct GitlabHost {
37    host: String,
38    origin: String,
39}
40
41impl GitlabHost {
42    /// Validates `raw` as a GitLab instance host.
43    ///
44    /// `format!("https://{raw}")` will happily absorb a `raw` containing `:`, `?`, `#`, `@`
45    /// or `/` — `gitlab.com?x` parses to a clean `https://gitlab.com` origin while the
46    /// caller still believes the host is `gitlab.com?x`. This rejects any `raw` containing
47    /// those characters *before* formatting, and asserts the parsed URL's host matches
48    /// `raw` (lowercased) afterwards, closing that gap.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`IndexUrlError`] when `raw` contains a URL-structural character, fails to
53    /// parse, is not `https`-eligible, carries userinfo, round-trips to a different host, or
54    /// resolves to a [`deps_core::net_policy::HostClass`] the current policy blocks.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
60    /// use deps_gitlab_ci::host::GitlabHost;
61    ///
62    /// let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly);
63    /// let host = GitlabHost::parse("gitlab.com", &policy).unwrap();
64    /// assert_eq!(host.host(), "gitlab.com");
65    /// assert_eq!(host.origin(), "https://gitlab.com");
66    ///
67    /// assert!(GitlabHost::parse("gitlab.com/evil", &policy).is_err());
68    /// assert!(GitlabHost::parse("169.254.169.254", &policy).is_err());
69    /// ```
70    pub fn parse(raw: &str, policy: &RegistryAccessPolicy) -> Result<Self, IndexUrlError> {
71        if raw.contains([':', '?', '#', '@', '/']) {
72            return Err(IndexUrlError::InvalidUrl(raw.to_string()));
73        }
74        let candidate = format!("https://{raw}");
75        let url = validate_index_url(&candidate, raw, "gitlab-ci", PolicyGate::Enforce(policy))?;
76        let raw_lowercased = raw.to_ascii_lowercase();
77        if url.host_str() != Some(raw_lowercased.as_str()) {
78            return Err(IndexUrlError::InvalidUrl(raw.to_string()));
79        }
80        Ok(Self {
81            host: raw_lowercased,
82            origin: url.origin().ascii_serialization(),
83        })
84    }
85
86    /// The verified, lowercased host string (no scheme, no path).
87    #[must_use]
88    pub fn host(&self) -> &str {
89        &self.host
90    }
91
92    /// Builds a [`GitlabHost`] pointed at `base_url` (e.g. a `mockito` server's
93    /// `http://127.0.0.1:PORT` URL), bypassing [`Self::parse`]'s `https`-only gate and
94    /// policy check entirely.
95    ///
96    /// Test-only: production code must always go through [`Self::parse`], which is the one
97    /// place a manifest- or configuration-sourced host is validated.
98    #[cfg(test)]
99    #[must_use]
100    pub fn for_test(base_url: &str) -> Self {
101        Self {
102            host: base_url
103                .trim_start_matches("http://")
104                .trim_start_matches("https://")
105                .to_string(),
106            origin: base_url.trim_end_matches('/').to_string(),
107        }
108    }
109
110    /// The normalized, ASCII-serialized origin (`https://{host}`), computed once at
111    /// construction.
112    #[must_use]
113    pub fn origin(&self) -> &str {
114        &self.origin
115    }
116
117    /// Reconstructs a [`GitlabHost`] from an already-validated origin string (spec §3.2 —
118    /// `GitlabRoute::origin` is only ever populated from a [`Self::origin`] value this type
119    /// itself produced), without re-running [`Self::parse`]'s policy/round-trip checks.
120    ///
121    /// `pub(crate)`, not `pub`: the trust boundary is this crate's own route table
122    /// (`crate::registry::GitlabCiRegistry`), which never stores an origin from any other
123    /// source.
124    #[must_use]
125    pub(crate) fn trusted(origin: &str) -> Self {
126        let host = origin
127            .strip_prefix("https://")
128            .unwrap_or(origin)
129            .to_string();
130        Self {
131            host,
132            origin: origin.to_string(),
133        }
134    }
135}
136
137/// Whether `s` is safe to splice into a GitLab API request path and/or is a syntactically
138/// well-formed project/component coordinate.
139///
140/// 2 or more `/`-separated segments, each non-empty and drawn from `[A-Za-z0-9._-]`, none of
141/// them a `.`/`..` dot segment, and the final segment not ending in `.git`/`.atom`.
142///
143/// A **syntactic safety gate**, not a semantic classifier — it is deliberately not asked to
144/// decide whether the first segment is a hostname or a group path, since a hostname's
145/// character set is a subset of the segment charset and both the bare path (`org/proj`) and
146/// the host-qualified name (`gitlab.com/org/proj`) must pass it. Shared by the fetch-URL
147/// gate ([`crate::client`]) and the formatter's display-URL gate
148/// ([`crate::formatter::GitlabCiFormatter`]), so the two cannot drift apart.
149///
150/// # Examples
151///
152/// ```
153/// use deps_gitlab_ci::host::is_valid_gitlab_coordinate;
154///
155/// assert!(is_valid_gitlab_coordinate("org/project"));
156/// assert!(is_valid_gitlab_coordinate("org/sub/group/project"));
157/// assert!(!is_valid_gitlab_coordinate("org"));
158/// assert!(!is_valid_gitlab_coordinate("org/.."));
159/// assert!(!is_valid_gitlab_coordinate("org/project.git"));
160/// ```
161#[must_use]
162pub fn is_valid_gitlab_coordinate(s: &str) -> bool {
163    let segments: Vec<&str> = s.split('/').collect();
164    if segments.len() < 2 || !segments.iter().all(|seg| is_valid_path_segment(seg)) {
165        return false;
166    }
167    let last = segments[segments.len() - 1];
168    !(last.ends_with(".git") || last.ends_with(".atom"))
169}
170
171/// Whether `seg` alone is safe to splice into a URL path segment: non-empty,
172/// `[A-Za-z0-9._-]`-only, and not a `.`/`..` dot segment.
173///
174/// Shared by [`is_valid_gitlab_coordinate`] (each `/`-separated segment) and
175/// `crate::parser`'s standalone component-name validation (a `component:` include's final
176/// path segment, checked independently of the project-path segments before it).
177#[must_use]
178pub(crate) fn is_valid_path_segment(seg: &str) -> bool {
179    !seg.is_empty()
180        && seg
181            .bytes()
182            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
183        && !deps_core::lsp_helpers::is_dot_segment(seg)
184}
185
186/// Tri-state outcome of resolving `registries.gitlab_instance_host` (spec FR-005a/FR-011a).
187///
188/// Distinct from a plain `Option<GitlabHost>` (security review, issue #466 H-security):
189/// [`token_host_origin`] must tell "unset" — the correct, intentional `gitlab.com` default —
190/// apart from "configured but rejected", which must **never** fall back to `gitlab.com`.
191/// Collapsing the two meant an invalid/policy-rejected value silently redirected
192/// `PRIVATE-TOKEN` to `gitlab.com`, leaking a self-hosted credential to the wrong host.
193/// [`GitlabInstanceHost::get`] still collapses `Unset`/`Invalid` to `None` for every other
194/// caller (host *resolution*, not token routing, where "can't resolve" is the same outcome
195/// either way).
196#[derive(Debug, Clone, PartialEq, Eq)]
197enum InstanceHostOutcome {
198    /// `registries.gitlab_instance_host` is not configured.
199    Unset,
200    /// Configured, but rejected by [`GitlabHost::parse`] (malformed, non-`https`-eligible,
201    /// or a blocked [`deps_core::net_policy::HostClass`]).
202    Invalid,
203    /// Configured and validated successfully.
204    Valid(GitlabHost),
205}
206
207/// Live-updatable, `Arc`-shareable handle to the `registries.gitlab_instance_host` setting
208/// (spec FR-011a).
209///
210/// The shared raw string lives outside this crate (`deps-lsp`'s `EcosystemRuntime`, a plain
211/// `Arc<RwLock<Option<String>>>` with no `#[cfg]` — see that struct's docs for why) and is
212/// threaded in at construction; every host-semantics decision (validation, memoization)
213/// lives here instead.
214///
215/// Validation runs on **read**, not on write: `Self::resolve` compares the current raw
216/// string and live policy against a memo of the last outcome, re-validating only when either
217/// changes (issue #588 critic M11 — the memo must be keyed on policy too, since
218/// [`RegistryAccessPolicy`] mutates in place: a host accepted under a looser policy must not
219/// keep resolving once the policy tightens). A rejected value is treated as unset for host
220/// *resolution* purposes ([`Self::get`] returns `None`), but is tracked distinctly
221/// (`InstanceHostOutcome::Invalid`) for token-host routing — see [`token_host_origin`].
222pub struct GitlabInstanceHost {
223    raw: Arc<RwLock<Option<String>>>,
224    policy: Arc<RegistryAccessPolicy>,
225    /// Last `(raw, policy)` this instance validated, and the outcome — both re-checked on
226    /// every [`Self::resolve`] so a stale outcome from either axis can never be served.
227    memo: RwLock<Option<(String, WorkspaceRegistryAccess, InstanceHostOutcome)>>,
228    /// Test-only escape hatch: when set, [`Self::resolve`] returns this directly, bypassing
229    /// [`GitlabHost::parse`]'s port-rejecting validation — needed only because a `mockito`
230    /// server's `127.0.0.1:PORT` host could otherwise never stand in for a *configured*
231    /// instance host in a test (production self-hosted GitLab hosts never need a port).
232    #[cfg(test)]
233    test_override: Option<GitlabHost>,
234}
235
236impl GitlabInstanceHost {
237    /// Builds a handle sharing `raw` (the config-owned raw string cell) and `policy` (the
238    /// same live [`RegistryAccessPolicy`] handle [`GitlabHost::parse`] gates against
239    /// elsewhere).
240    #[must_use]
241    pub fn new(raw: Arc<RwLock<Option<String>>>, policy: Arc<RegistryAccessPolicy>) -> Self {
242        Self {
243            raw,
244            policy,
245            memo: RwLock::new(None),
246            #[cfg(test)]
247            test_override: None,
248        }
249    }
250
251    /// Test-only: builds a handle whose [`Self::get`] always returns `host` directly. See
252    /// [`Self::test_override`]'s doc for why this bypass exists.
253    #[cfg(test)]
254    #[must_use]
255    pub(crate) fn for_test(host: GitlabHost) -> Self {
256        Self {
257            raw: Arc::new(RwLock::new(None)),
258            policy: Arc::new(RegistryAccessPolicy::default()),
259            memo: RwLock::new(None),
260            test_override: Some(host),
261        }
262    }
263
264    /// The currently configured, validated instance host — `None` when unset or when the
265    /// configured value fails validation (logged once per distinct `(raw, policy)` pair, not
266    /// per read).
267    ///
268    /// Collapses `InstanceHostOutcome::Unset` and `InstanceHostOutcome::Invalid` to the
269    /// same `None`: for host *resolution* (what a `project:`/`$...`-relative `component:`
270    /// include resolves against), "not configured" and "configured but rejected" are the
271    /// same outcome. They are **not** the same outcome for token routing — see
272    /// [`token_host_origin`], which calls `Self::resolve` directly instead.
273    #[must_use]
274    pub fn get(&self) -> Option<GitlabHost> {
275        match self.resolve() {
276            InstanceHostOutcome::Valid(host) => Some(host),
277            InstanceHostOutcome::Unset | InstanceHostOutcome::Invalid => None,
278        }
279    }
280
281    /// The full tri-state outcome — see [`InstanceHostOutcome`]'s doc for why `Unset` and
282    /// `Invalid` must stay distinguishable here even though [`Self::get`] collapses them.
283    fn resolve(&self) -> InstanceHostOutcome {
284        #[cfg(test)]
285        if let Some(host) = &self.test_override {
286            return InstanceHostOutcome::Valid(host.clone());
287        }
288
289        let Some(raw) = self
290            .raw
291            .read()
292            .expect("gitlab_instance_host raw lock poisoned")
293            .clone()
294        else {
295            return InstanceHostOutcome::Unset;
296        };
297        let policy_now = self.policy.get();
298
299        if let Some((cached_raw, cached_policy, outcome)) = self
300            .memo
301            .read()
302            .expect("gitlab_instance_host memo lock poisoned")
303            .as_ref()
304            && *cached_raw == raw
305            && *cached_policy == policy_now
306        {
307            return outcome.clone();
308        }
309
310        let outcome = match GitlabHost::parse(&raw, &self.policy) {
311            Ok(host) => InstanceHostOutcome::Valid(host),
312            Err(e) => {
313                tracing::warn!(
314                    error = %e,
315                    "registries.gitlab_instance_host is invalid; treating it as unset for host \
316                     resolution and disabling GITLAB_TOKEN entirely (it is not redirected to \
317                     gitlab.com)"
318                );
319                InstanceHostOutcome::Invalid
320            }
321        };
322        *self
323            .memo
324            .write()
325            .expect("gitlab_instance_host memo lock poisoned") =
326            Some((raw, policy_now, outcome.clone()));
327        outcome
328    }
329}
330
331/// The one host `PRIVATE-TOKEN` may be attached to (FR-005a): the configured
332/// `registries.gitlab_instance_host`'s origin when set, **replacing** — not joined with —
333/// [`GITLAB_COM_ORIGIN`] otherwise.
334///
335/// Returns `None` when the setting is configured but **invalid** (security review, issue
336/// #466): the pre-fix version collapsed "unset" and "invalid" into the same fallback,
337/// silently redirecting `PRIVATE-TOKEN` to `gitlab.com` for a rejected value instead of
338/// disabling it — an invalid value must send the token nowhere, not to a default host the
339/// user never configured. Callers compare with `.is_some_and(|o| o == host.origin())`
340/// (never `.unwrap_or(...)`) so `None` can never equal any host's origin.
341///
342/// # Examples
343///
344/// ```
345/// use deps_core::net_policy::RegistryAccessPolicy;
346/// use deps_gitlab_ci::host::{GITLAB_COM_ORIGIN, GitlabInstanceHost, token_host_origin};
347/// use std::sync::{Arc, RwLock};
348///
349/// let policy = Arc::new(RegistryAccessPolicy::default());
350/// let unset = GitlabInstanceHost::new(Arc::new(RwLock::new(None)), Arc::clone(&policy));
351/// assert_eq!(token_host_origin(&unset).as_deref(), Some(GITLAB_COM_ORIGIN));
352///
353/// let set = GitlabInstanceHost::new(
354///     Arc::new(RwLock::new(Some("gitlab.mycorp.dev".to_string()))),
355///     Arc::clone(&policy),
356/// );
357/// assert_eq!(token_host_origin(&set).as_deref(), Some("https://gitlab.mycorp.dev"));
358///
359/// // An invalid value is disabled outright, never redirected to `gitlab.com`.
360/// let invalid = GitlabInstanceHost::new(
361///     Arc::new(RwLock::new(Some("127.0.0.1".to_string()))),
362///     policy,
363/// );
364/// assert_eq!(token_host_origin(&invalid), None);
365/// ```
366#[must_use]
367pub fn token_host_origin(instance_host: &GitlabInstanceHost) -> Option<String> {
368    match instance_host.resolve() {
369        InstanceHostOutcome::Unset => Some(GITLAB_COM_ORIGIN.to_string()),
370        InstanceHostOutcome::Valid(host) => Some(host.origin().to_string()),
371        InstanceHostOutcome::Invalid => None,
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn policy(access: WorkspaceRegistryAccess) -> RegistryAccessPolicy {
380        RegistryAccessPolicy::new(access)
381    }
382
383    #[test]
384    fn test_gitlab_host_parse_accepts_plain_host() {
385        let p = policy(WorkspaceRegistryAccess::PublicOnly);
386        let host = GitlabHost::parse("gitlab.com", &p).unwrap();
387        assert_eq!(host.host(), "gitlab.com");
388        assert_eq!(host.origin(), GITLAB_COM_ORIGIN);
389    }
390
391    #[test]
392    fn test_gitlab_host_parse_lowercases() {
393        let p = policy(WorkspaceRegistryAccess::PublicOnly);
394        let host = GitlabHost::parse("GitLab.COM", &p).unwrap();
395        assert_eq!(host.host(), "gitlab.com");
396    }
397
398    #[test]
399    fn test_gitlab_host_parse_rejects_structural_characters() {
400        let p = policy(WorkspaceRegistryAccess::All);
401        for raw in [
402            "gitlab.com?x",
403            "gitlab.com/x",
404            "gitlab.com#x",
405            "gitlab.com:8080@evil.test",
406            "user@gitlab.com",
407        ] {
408            assert!(
409                GitlabHost::parse(raw, &p).is_err(),
410                "expected {raw} to be rejected"
411            );
412        }
413    }
414
415    #[test]
416    fn test_gitlab_host_parse_rejects_blocked_host_class() {
417        let p = policy(WorkspaceRegistryAccess::PublicOnly);
418        for raw in ["127.0.0.1", "169.254.169.254", "10.0.0.1", "localhost"] {
419            assert!(
420                GitlabHost::parse(raw, &p).is_err(),
421                "expected {raw} to be rejected"
422            );
423        }
424    }
425
426    #[test]
427    fn test_gitlab_host_parse_allows_blocked_host_class_under_all_policy() {
428        let p = policy(WorkspaceRegistryAccess::All);
429        assert!(GitlabHost::parse("10.0.0.1", &p).is_ok());
430    }
431
432    #[test]
433    fn test_is_valid_gitlab_coordinate_accepts_nested_subgroups() {
434        assert!(is_valid_gitlab_coordinate("org/sub/group/project"));
435        assert!(is_valid_gitlab_coordinate("org/project"));
436        assert!(is_valid_gitlab_coordinate("gitlab.com/org/project"));
437    }
438
439    #[test]
440    fn test_is_valid_gitlab_coordinate_rejects_single_segment() {
441        assert!(!is_valid_gitlab_coordinate("org"));
442        assert!(!is_valid_gitlab_coordinate(""));
443    }
444
445    #[test]
446    fn test_is_valid_gitlab_coordinate_rejects_dot_segments() {
447        assert!(!is_valid_gitlab_coordinate("org/.."));
448        assert!(!is_valid_gitlab_coordinate("org/."));
449        assert!(!is_valid_gitlab_coordinate("../repo"));
450    }
451
452    #[test]
453    fn test_is_valid_gitlab_coordinate_rejects_git_atom_suffix() {
454        assert!(!is_valid_gitlab_coordinate("org/project.git"));
455        assert!(!is_valid_gitlab_coordinate("org/project.atom"));
456    }
457
458    #[test]
459    fn test_is_valid_gitlab_coordinate_rejects_bad_charset() {
460        assert!(!is_valid_gitlab_coordinate("org/pro ject"));
461        assert!(!is_valid_gitlab_coordinate("org//project"));
462    }
463
464    #[test]
465    fn test_gitlab_instance_host_unset_returns_none() {
466        let policy = Arc::new(RegistryAccessPolicy::default());
467        let raw = Arc::new(RwLock::new(None));
468        let handle = GitlabInstanceHost::new(raw, policy);
469        assert!(handle.get().is_none());
470    }
471
472    #[test]
473    fn test_gitlab_instance_host_valid_value_resolves() {
474        let policy = Arc::new(RegistryAccessPolicy::default());
475        let raw = Arc::new(RwLock::new(Some("gitlab.mycorp.dev".to_string())));
476        let handle = GitlabInstanceHost::new(raw, policy);
477        let host = handle.get().unwrap();
478        assert_eq!(host.host(), "gitlab.mycorp.dev");
479    }
480
481    #[test]
482    fn test_gitlab_instance_host_invalid_value_reads_back_as_none() {
483        let policy = Arc::new(RegistryAccessPolicy::default());
484        for bad in ["http://gitlab.mycorp.dev", "127.0.0.1", "169.254.169.254"] {
485            let raw = Arc::new(RwLock::new(Some(bad.to_string())));
486            let handle = GitlabInstanceHost::new(raw, Arc::clone(&policy));
487            assert!(handle.get().is_none(), "expected {bad} to be rejected");
488        }
489    }
490
491    #[test]
492    fn test_gitlab_instance_host_memo_invalidates_on_raw_change() {
493        let policy = Arc::new(RegistryAccessPolicy::default());
494        let raw = Arc::new(RwLock::new(Some("gitlab.mycorp.dev".to_string())));
495        let handle = GitlabInstanceHost::new(Arc::clone(&raw), policy);
496        assert_eq!(handle.get().unwrap().host(), "gitlab.mycorp.dev");
497
498        *raw.write().unwrap() = Some("gitlab.other.dev".to_string());
499        assert_eq!(handle.get().unwrap().host(), "gitlab.other.dev");
500    }
501
502    /// Issue #588 critic M11 regression: a host validated while the policy allows it must
503    /// stop resolving once the policy tightens to reject its class — the memo must be keyed
504    /// on policy too, not just the raw string.
505    #[test]
506    fn test_gitlab_instance_host_memo_invalidates_on_policy_tightening() {
507        let policy = Arc::new(RegistryAccessPolicy::new(WorkspaceRegistryAccess::All));
508        let raw = Arc::new(RwLock::new(Some("10.0.0.1".to_string())));
509        let handle = GitlabInstanceHost::new(raw, Arc::clone(&policy));
510        assert!(
511            handle.get().is_some(),
512            "a private-range host is valid under the All policy"
513        );
514
515        policy.set(WorkspaceRegistryAccess::PublicOnly);
516        assert!(
517            handle.get().is_none(),
518            "the same host must be rejected once the policy tightens, not served from a stale memo"
519        );
520    }
521
522    #[test]
523    fn test_token_host_origin_unset_is_gitlab_com() {
524        let policy = Arc::new(RegistryAccessPolicy::default());
525        let handle = GitlabInstanceHost::new(Arc::new(RwLock::new(None)), policy);
526        assert_eq!(
527            token_host_origin(&handle).as_deref(),
528            Some(GITLAB_COM_ORIGIN)
529        );
530    }
531
532    #[test]
533    fn test_token_host_origin_set_replaces_gitlab_com() {
534        let policy = Arc::new(RegistryAccessPolicy::default());
535        let raw = Arc::new(RwLock::new(Some("gitlab.mycorp.dev".to_string())));
536        let handle = GitlabInstanceHost::new(raw, policy);
537        assert_eq!(
538            token_host_origin(&handle).as_deref(),
539            Some("https://gitlab.mycorp.dev")
540        );
541        assert_ne!(
542            token_host_origin(&handle).as_deref(),
543            Some(GITLAB_COM_ORIGIN)
544        );
545    }
546
547    /// Security regression (#466 review): an invalid/policy-rejected instance host must
548    /// disable the token outright, never fall back to `gitlab.com` — collapsing "unset" and
549    /// "invalid" into one `None` (pre-fix) silently redirected `PRIVATE-TOKEN` to
550    /// `gitlab.com`, leaking a self-hosted credential to the wrong host.
551    #[test]
552    fn test_token_host_origin_invalid_value_disables_token_not_gitlab_com() {
553        let policy = Arc::new(RegistryAccessPolicy::default());
554        let raw = Arc::new(RwLock::new(Some("127.0.0.1".to_string())));
555        let handle = GitlabInstanceHost::new(raw, policy);
556        assert_eq!(token_host_origin(&handle), None);
557    }
558}