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}