Skip to main content

deps_gitlab_ci/
parser.rs

1//! `.gitlab-ci.yml`-syntax parser using `yaml-rust2`'s event-driven
2//! (`MarkedEventReceiver`) API.
3//!
4//! Tracks the top-level `include:` key's value and, within it, each entry's
5//! `project:`/`ref:`/`component:`/`template:`/`remote:`/`local:` keys — `template:`,
6//! `remote:` and a `local:` key (or a bare-string `include:` list item, GitLab's `local:`
7//! shorthand) are recognized and skipped gracefully (spec FR-003); `image:`/`services:`
8//! entries are never visited at all, since they never appear under `include:` (FR-016
9//! falls out for free).
10//!
11//! # Multi-document input
12//!
13//! A GitLab CI **component** file uses the `spec:` header form (`spec: … \n--- \n job:`).
14//! Per-document parser state is reset at each `Event::DocumentStart`/`DocumentEnd`, so
15//! document 1's nesting never mis-scopes document 2's top-level `include:`.
16//!
17//! # Host resolution and the per-document fan-out cap
18//!
19//! Every dependency's host is resolved here (not deferred to the registry), since the
20//! per-document cap on distinct literal `component:` hosts (spec plan §4.6) must be
21//! enforced at the point a host string first turns into a fetch target. A `project:`
22//! include (which never carries a host segment) and a `$CI_SERVER_FQDN`-relative
23//! `component:` include resolve against `registries.gitlab_instance_host` when set, or are
24//! left [`crate::types::HostRef::Unresolved`] otherwise (spec FR-011a/FR-012).
25
26use crate::host::{
27    GitlabHost, GitlabInstanceHost, is_valid_gitlab_coordinate, is_valid_path_segment,
28};
29use crate::types::{
30    EndpointKind, GitlabCiDependency, GitlabCiParseResult, GitlabRoute, HostRef, IncludeKind,
31    PinStyle,
32};
33use deps_core::lsp_helpers::{
34    CharOffsets, LineOffsetTable, is_full_sha, is_tag_shaped, locate_value_span,
35    warn_rejected_value,
36};
37use deps_core::net_policy::RegistryAccessPolicy;
38use deps_core::parser::DependencySource;
39use deps_core::{DepsError, Result};
40use std::collections::HashSet;
41use tower_lsp_server::ls_types::{Range, Uri};
42use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
43use yaml_rust2::scanner::{Marker, TScalarStyle};
44
45/// Bound on distinct literal `component:` hosts admitted per document (spec plan §4.6) —
46/// the bound that matters for a `didOpen` burst, protecting `HttpCache`'s unbounded
47/// per-origin transport pool from a `.gitlab-ci.yml` naming N distinct hosts on one parse.
48const MAX_HOSTS_PER_DOCUMENT: usize = 8;
49
50/// Placeholder display text for a `project:` include's implicit host, and for a
51/// `$CI_SERVER_FQDN`-relative `component:` include with no configured instance host.
52const CI_SERVER_FQDN: &str = "$CI_SERVER_FQDN";
53
54/// Which container kind a [`Frame`] represents.
55#[derive(Clone, Copy, PartialEq, Eq)]
56enum FrameKind {
57    Mapping,
58    Sequence,
59}
60
61/// What a [`Frame`] means for include-entry extraction purposes.
62#[derive(Clone, Copy, PartialEq, Eq)]
63enum FrameRole {
64    /// The document's root mapping — scanned only for a top-level `include:` key.
65    Root,
66    /// The value of a top-level `include:` key, when it is a sequence of entries.
67    IncludeValue,
68    /// One include entry mapping — either a `include:`'s single-mapping form, or one
69    /// mapping item inside an [`FrameRole::IncludeValue`] sequence. Its direct scalar keys
70    /// (`project`/`ref`/`component`/`template`/`remote`/`local`) are captured into
71    /// [`Frame::entry`].
72    IncludeEntry,
73    /// Anything else — a job body, `inputs:`, `rules:`, or any other structure this parser
74    /// does not need to look inside.
75    Irrelevant,
76}
77
78/// Which key (if any) a [`FrameRole::Root`] or [`FrameRole::IncludeEntry`] mapping frame is
79/// currently awaiting the value for.
80#[derive(Clone, Copy, PartialEq, Eq)]
81enum PendingKey {
82    None,
83    Include,
84    Project,
85    Ref,
86    Component,
87    Template,
88    Remote,
89    Local,
90}
91
92fn key_for(role: FrameRole, text: &str) -> PendingKey {
93    match role {
94        FrameRole::Root => {
95            if text == "include" {
96                PendingKey::Include
97            } else {
98                PendingKey::None
99            }
100        }
101        FrameRole::IncludeEntry => match text {
102            "project" => PendingKey::Project,
103            "ref" => PendingKey::Ref,
104            "component" => PendingKey::Component,
105            "template" => PendingKey::Template,
106            "remote" => PendingKey::Remote,
107            "local" => PendingKey::Local,
108            _ => PendingKey::None,
109        },
110        FrameRole::IncludeValue | FrameRole::Irrelevant => PendingKey::None,
111    }
112}
113
114/// One raw scalar value captured from an include entry: its text, YAML scalar style, and
115/// `yaml-rust2` char index (for span re-derivation after parsing completes).
116type RawField = (String, TScalarStyle, usize);
117
118/// One `include:` entry's raw, not-yet-classified field values, collected during the event
119/// stream and finalized into a [`GitlabCiDependency`] after parsing completes.
120#[derive(Default)]
121struct RawEntry {
122    project: Option<RawField>,
123    ref_field: Option<RawField>,
124    component: Option<RawField>,
125    has_template: bool,
126    has_remote: bool,
127    has_local: bool,
128}
129
130struct Frame {
131    kind: FrameKind,
132    role: FrameRole,
133    /// Only meaningful for `kind == Mapping`.
134    awaiting_key: bool,
135    /// Only meaningful for `kind == Mapping`.
136    pending_key: PendingKey,
137    /// Only meaningful for `role == IncludeEntry`.
138    entry: RawEntry,
139}
140
141/// Collects every `include:` entry's raw field values, gated to exactly the top-level
142/// `include:` key's subtree.
143struct GitlabCiReceiver {
144    stack: Vec<Frame>,
145    entries: Vec<RawEntry>,
146}
147
148impl GitlabCiReceiver {
149    fn new() -> Self {
150        Self {
151            stack: Vec::new(),
152            entries: Vec::new(),
153        }
154    }
155
156    fn push_container(&mut self, kind: FrameKind) {
157        let role = match self.stack.last() {
158            None => {
159                if kind == FrameKind::Mapping {
160                    FrameRole::Root
161                } else {
162                    FrameRole::Irrelevant
163                }
164            }
165            Some(parent) if parent.kind == FrameKind::Sequence => {
166                if parent.role == FrameRole::IncludeValue && kind == FrameKind::Mapping {
167                    FrameRole::IncludeEntry
168                } else {
169                    FrameRole::Irrelevant
170                }
171            }
172            Some(parent) => match (parent.role, parent.pending_key, kind) {
173                (FrameRole::Root, PendingKey::Include, FrameKind::Sequence) => {
174                    FrameRole::IncludeValue
175                }
176                (FrameRole::Root, PendingKey::Include, FrameKind::Mapping) => {
177                    FrameRole::IncludeEntry
178                }
179                _ => FrameRole::Irrelevant,
180            },
181        };
182        self.consume_pending_value();
183        self.stack.push(Frame {
184            kind,
185            role,
186            awaiting_key: true,
187            pending_key: PendingKey::None,
188            entry: RawEntry::default(),
189        });
190    }
191
192    fn consume_pending_value(&mut self) {
193        if let Some(top) = self.stack.last_mut()
194            && top.kind == FrameKind::Mapping
195        {
196            top.awaiting_key = true;
197            top.pending_key = PendingKey::None;
198        }
199    }
200
201    fn pop_container(&mut self) {
202        if let Some(frame) = self.stack.pop()
203            && frame.role == FrameRole::IncludeEntry
204        {
205            self.entries.push(frame.entry);
206        }
207        self.consume_pending_value();
208    }
209}
210
211impl MarkedEventReceiver for GitlabCiReceiver {
212    fn on_event(&mut self, event: Event, marker: Marker) {
213        match event {
214            Event::MappingStart(..) => self.push_container(FrameKind::Mapping),
215            Event::SequenceStart(..) => self.push_container(FrameKind::Sequence),
216            Event::MappingEnd | Event::SequenceEnd => self.pop_container(),
217            Event::Scalar(value, style, _anchor, _tag) => {
218                let Some(frame) = self.stack.last_mut() else {
219                    return;
220                };
221                if frame.kind != FrameKind::Mapping {
222                    // A bare scalar sequence item (e.g. `include: - templates/x.yml`, the
223                    // `local:` shorthand) carries nothing to record; irrelevant items are
224                    // ignored the same way.
225                    return;
226                }
227                if frame.awaiting_key {
228                    frame.pending_key = key_for(frame.role, &value);
229                    frame.awaiting_key = false;
230                } else {
231                    if frame.role == FrameRole::IncludeEntry {
232                        match frame.pending_key {
233                            PendingKey::Project => {
234                                frame.entry.project = Some((value, style, marker.index()));
235                            }
236                            PendingKey::Ref => {
237                                frame.entry.ref_field = Some((value, style, marker.index()));
238                            }
239                            PendingKey::Component => {
240                                frame.entry.component = Some((value, style, marker.index()));
241                            }
242                            PendingKey::Template => frame.entry.has_template = true,
243                            PendingKey::Remote => frame.entry.has_remote = true,
244                            PendingKey::Local => frame.entry.has_local = true,
245                            PendingKey::None | PendingKey::Include => {}
246                        }
247                    }
248                    frame.awaiting_key = true;
249                    frame.pending_key = PendingKey::None;
250                }
251            }
252            // A `*anchor` alias value must still clear the pending-key slot, mirroring
253            // `deps-github-actions`'s identical fix — GitLab CI files use YAML anchors
254            // heavily for job reuse, so this is a real, not merely defensive, case here.
255            Event::Alias(_) => self.consume_pending_value(),
256            Event::DocumentStart | Event::DocumentEnd => {
257                // A GitLab CI **component** file's `spec:` header form is multi-document
258                // (`spec: … \n--- \n job:`); resetting here stops document 1's nesting from
259                // mis-scoping document 2's top-level `include:` in either direction.
260                self.stack.clear();
261            }
262            Event::Nothing | Event::StreamStart | Event::StreamEnd => {}
263        }
264    }
265}
266
267/// Whether `origin` is already admitted, or can be newly admitted under
268/// [`MAX_HOSTS_PER_DOCUMENT`]. Mutates `admitted` on a successful new admission.
269fn admit_origin(admitted: &mut HashSet<String>, origin: &str) -> bool {
270    if admitted.contains(origin) {
271        return true;
272    }
273    if admitted.len() >= MAX_HOSTS_PER_DOCUMENT {
274        return false;
275    }
276    admitted.insert(origin.to_string());
277    true
278}
279
280/// Classifies a `project:` include's `ref:` text — the same two-way SHA/tag-shape test
281/// `deps-github-actions` uses, with no third "confirmed by the registry" state (this crate
282/// has no per-repository tag/SHA cross-reference the way GHA's `TagIndex` does).
283fn classify_project_pin(ref_text: &str) -> PinStyle {
284    if is_full_sha(ref_text) {
285        PinStyle::Sha
286    } else if is_tag_shaped(ref_text) {
287        PinStyle::Tag
288    } else {
289        PinStyle::Branch
290    }
291}
292
293fn host_qualified_name(host: &HostRef, project_path: &str, component_name: Option<&str>) -> String {
294    let base = match host {
295        HostRef::Literal(h) => format!("{}/{project_path}", h.host()),
296        HostRef::Unresolved(_) | HostRef::CapacityRefused(_) => project_path.to_string(),
297    };
298    match component_name {
299        Some(name) => format!("{base}/{name}"),
300        None => base,
301    }
302}
303
304fn build_source_and_route(
305    host: &HostRef,
306    endpoint: EndpointKind,
307) -> (DependencySource, Option<(String, GitlabRoute)>) {
308    match host {
309        HostRef::Literal(h) => {
310            let route_key =
311                deps_core::hash_routing_key("gitlab", [h.origin(), endpoint.as_str()].into_iter());
312            let route = GitlabRoute {
313                origin: h.origin().to_string(),
314                endpoint,
315            };
316            (
317                DependencySource::AlternateRegistry {
318                    index: route_key.clone(),
319                    mirrors_crates_io: false,
320                },
321                Some((route_key, route)),
322            )
323        }
324        HostRef::Unresolved(raw) => (DependencySource::CustomRegistry { url: raw.clone() }, None),
325        HostRef::CapacityRefused(origin) => (
326            DependencySource::CustomRegistry {
327                url: origin.clone(),
328            },
329            None,
330        ),
331    }
332}
333
334fn resolve_project_host(instance_host: &GitlabInstanceHost) -> HostRef {
335    instance_host.get().map_or_else(
336        || HostRef::Unresolved(CI_SERVER_FQDN.to_string()),
337        HostRef::Literal,
338    )
339}
340
341fn resolve_component_host(
342    host_expr: &str,
343    policy: &RegistryAccessPolicy,
344    instance_host: &GitlabInstanceHost,
345    admitted_origins: &mut HashSet<String>,
346) -> HostRef {
347    if host_expr.starts_with('$') {
348        return instance_host.get().map_or_else(
349            || HostRef::Unresolved(host_expr.to_string()),
350            HostRef::Literal,
351        );
352    }
353    match GitlabHost::parse(host_expr, policy) {
354        Ok(host) if admit_origin(admitted_origins, host.origin()) => HostRef::Literal(host),
355        // M-a (#466 review): the host itself validated fine — only the per-document cap
356        // refused it — so this is `CapacityRefused`, not `Unresolved`: its origin is a known,
357        // usable value, unlike a genuinely unresolvable host.
358        Ok(host) => {
359            warn_rejected_value(
360                "admit_origin",
361                "gitlab-ci component: host (per-document cap)",
362                host_expr,
363            );
364            HostRef::CapacityRefused(host.origin().to_string())
365        }
366        Err(_) => HostRef::Unresolved(host_expr.to_string()),
367    }
368}
369
370fn make_range(line_table: &LineOffsetTable, content: &str, start: usize, end: usize) -> Range {
371    Range::new(
372        line_table.byte_offset_to_position(content, start),
373        line_table.byte_offset_to_position(content, end),
374    )
375}
376
377fn build_project_dependency(
378    content: &str,
379    line_table: &LineOffsetTable,
380    char_offsets: &CharOffsets,
381    instance_host: &GitlabInstanceHost,
382    project_field: RawField,
383    ref_field: Option<RawField>,
384) -> Option<(GitlabCiDependency, Option<(String, GitlabRoute)>)> {
385    let (raw_project, project_style, project_char_index) = project_field;
386    if !is_valid_gitlab_coordinate(&raw_project) {
387        warn_rejected_value(
388            "is_valid_gitlab_coordinate",
389            "gitlab-ci project: value",
390            &raw_project,
391        );
392        return None;
393    }
394
395    let value_start = char_offsets.byte_offset(project_char_index);
396    let (raw_start, raw_end) = locate_value_span(content, value_start, &raw_project)?;
397    let name_range = make_range(line_table, content, raw_start, raw_end);
398
399    let host = resolve_project_host(instance_host);
400    let name = host_qualified_name(&host, &raw_project, None);
401
402    let (version_req, version_range, pin, is_plain_scalar) = match ref_field {
403        Some((ref_text, ref_style, ref_char_index)) => {
404            let ref_value_start = char_offsets.byte_offset(ref_char_index);
405            let (rs, re) = locate_value_span(content, ref_value_start, &ref_text)?;
406            let range = make_range(line_table, content, rs, re);
407            let pin = classify_project_pin(&ref_text);
408            let plain = ref_style == TScalarStyle::Plain;
409            (Some(ref_text.into()), Some(range), Some(pin), plain)
410        }
411        None => (None, None, None, project_style == TScalarStyle::Plain),
412    };
413
414    let (source, route) = build_source_and_route(&host, EndpointKind::Tags);
415
416    Some((
417        GitlabCiDependency {
418            name: name.into(),
419            name_range,
420            version_req,
421            version_range,
422            version_literal: None,
423            source,
424            is_plain_scalar,
425            kind: IncludeKind::Project,
426            host,
427            pin,
428            project_path: raw_project,
429        },
430        route,
431    ))
432}
433
434fn build_component_dependency(
435    content: &str,
436    line_table: &LineOffsetTable,
437    char_offsets: &CharOffsets,
438    policy: &RegistryAccessPolicy,
439    instance_host: &GitlabInstanceHost,
440    admitted_origins: &mut HashSet<String>,
441    component_field: RawField,
442) -> Option<(GitlabCiDependency, Option<(String, GitlabRoute)>)> {
443    let (raw_component, style, char_index) = component_field;
444    let Some((prefix, ref_text)) = raw_component.split_once('@') else {
445        warn_rejected_value(
446            "classify_component_value",
447            "gitlab-ci component: value (missing @version)",
448            &raw_component,
449        );
450        return None;
451    };
452    if ref_text.is_empty() {
453        warn_rejected_value(
454            "classify_component_value",
455            "gitlab-ci component: value (empty version)",
456            &raw_component,
457        );
458        return None;
459    }
460
461    // `<fqdn>/<full-path-to-component-directory>@<version>`: the last path segment before
462    // `@` is the component name (GitLab's own documented shape — see this crate's `lib.rs`
463    // pin-contract table); everything between the host and the component name is the
464    // project path. Minimum 4 segments: host + at least a 2-segment project path + the
465    // component name.
466    let segments: Vec<&str> = prefix.split('/').collect();
467    if segments.len() < 4 {
468        warn_rejected_value(
469            "classify_component_value",
470            "gitlab-ci component: value (too few path segments)",
471            &raw_component,
472        );
473        return None;
474    }
475    let host_expr = segments[0];
476    let component_name = segments[segments.len() - 1];
477    let project_path = segments[1..segments.len() - 1].join("/");
478    if !is_valid_gitlab_coordinate(&project_path) || !is_valid_path_segment(component_name) {
479        warn_rejected_value(
480            "classify_component_value",
481            "gitlab-ci component: value (malformed path)",
482            &raw_component,
483        );
484        return None;
485    }
486
487    let value_start = char_offsets.byte_offset(char_index);
488    let (raw_start, raw_end) = locate_value_span(content, value_start, &raw_component)?;
489    let name_end = raw_start + prefix.len();
490    let ref_start = name_end + 1; // skip '@'
491    let name_range = make_range(line_table, content, raw_start, name_end);
492    let version_range = make_range(line_table, content, ref_start, raw_end);
493
494    let host = resolve_component_host(host_expr, policy, instance_host, admitted_origins);
495    let name = host_qualified_name(&host, &project_path, Some(component_name));
496    let pin = crate::component::classify_component_pin_style(ref_text);
497    let (source, route) = build_source_and_route(&host, EndpointKind::Releases);
498
499    Some((
500        GitlabCiDependency {
501            name: name.into(),
502            name_range,
503            version_req: Some(ref_text.into()),
504            version_range: Some(version_range),
505            version_literal: None,
506            source,
507            is_plain_scalar: style == TScalarStyle::Plain,
508            kind: IncludeKind::Component,
509            host,
510            pin: Some(pin),
511            project_path,
512        },
513        route,
514    ))
515}
516
517fn build_dependency(
518    content: &str,
519    line_table: &LineOffsetTable,
520    char_offsets: &CharOffsets,
521    policy: &RegistryAccessPolicy,
522    instance_host: &GitlabInstanceHost,
523    admitted_origins: &mut HashSet<String>,
524    entry: RawEntry,
525) -> Option<(GitlabCiDependency, Option<(String, GitlabRoute)>)> {
526    // `template:`/`remote:`/a `local:` key are recognized and skipped gracefully — not
527    // version-pinnable (spec FR-003).
528    if entry.has_template || entry.has_remote || entry.has_local {
529        return None;
530    }
531    if let Some(component_field) = entry.component {
532        return build_component_dependency(
533            content,
534            line_table,
535            char_offsets,
536            policy,
537            instance_host,
538            admitted_origins,
539            component_field,
540        );
541    }
542    if let Some(project_field) = entry.project {
543        return build_project_dependency(
544            content,
545            line_table,
546            char_offsets,
547            instance_host,
548            project_field,
549            entry.ref_field,
550        );
551    }
552    tracing::debug!("gitlab-ci include entry has neither project: nor component:; skipping");
553    None
554}
555
556/// Parses a `.gitlab-ci.yml`-syntax file and returns every `include:` dependency found,
557/// with LSP position tracking, host resolution, and routing.
558///
559/// Gated first by [`deps_core::check_yaml_nesting_depth`]/[`deps_core::check_yaml_expansion`],
560/// which return a real [`DepsError::ParseError`]. A downstream YAML syntax error degrades to
561/// an **empty** [`GitlabCiParseResult`] (logged at `debug`) rather than propagating.
562///
563/// # Errors
564///
565/// Returns [`DepsError::ParseError`] only when `content` exceeds the shared YAML
566/// nesting-depth or expansion-size gate.
567///
568/// # Examples
569///
570/// ```
571/// use deps_core::Dependency;
572/// use deps_core::net_policy::RegistryAccessPolicy;
573/// use deps_gitlab_ci::GitlabInstanceHost;
574/// use deps_gitlab_ci::parse_gitlab_ci_yaml;
575/// use std::sync::{Arc, RwLock};
576///
577/// let content = "include:\n  - project: org/proj\n    ref: v1.0.0\n";
578/// let uri = deps_core::test_util::test_uri("/repo/.gitlab-ci.yml");
579/// let policy = RegistryAccessPolicy::default();
580/// let instance_host = GitlabInstanceHost::new(Arc::new(RwLock::new(None)), Arc::new(policy));
581///
582/// let policy = RegistryAccessPolicy::default();
583/// let result = parse_gitlab_ci_yaml(content, &uri, &policy, &instance_host).unwrap();
584/// assert_eq!(result.dependencies.len(), 1);
585/// ```
586pub fn parse_gitlab_ci_yaml(
587    content: &str,
588    uri: &Uri,
589    policy: &RegistryAccessPolicy,
590    instance_host: &GitlabInstanceHost,
591) -> Result<GitlabCiParseResult> {
592    if let Err(depth) =
593        deps_core::check_yaml_nesting_depth(content, deps_core::MAX_YAML_NESTING_DEPTH)
594    {
595        return Err(DepsError::ParseError {
596            file_type: "gitlab-ci.yml".into(),
597            source: Box::new(std::io::Error::other(format!(
598                "YAML nesting depth {depth} exceeds maximum of {}",
599                deps_core::MAX_YAML_NESTING_DEPTH
600            ))),
601        });
602    }
603    if let Err(bytes) = deps_core::check_yaml_expansion(content, deps_core::MAX_YAML_EXPANDED_BYTES)
604    {
605        return Err(DepsError::ParseError {
606            file_type: "gitlab-ci.yml".into(),
607            source: Box::new(std::io::Error::other(format!(
608                "YAML expansion {bytes} bytes exceeds maximum of {} bytes",
609                deps_core::MAX_YAML_EXPANDED_BYTES
610            ))),
611        });
612    }
613
614    let mut receiver = GitlabCiReceiver::new();
615    let mut parser = Parser::new_from_str(content);
616    // `multi: true` — unlike `deps-github-actions`'s workflow files, a GitLab CI
617    // **component** file's `spec:` header form is genuinely multi-document
618    // (`spec: … \n--- \n job:`); `Parser::load(_, false)` stops after the first document.
619    if let Err(e) = parser.load(&mut receiver, true) {
620        tracing::debug!(error = %e, "failed to parse GitLab CI YAML, treating as empty");
621        return Ok(GitlabCiParseResult {
622            dependencies: Vec::new(),
623            routes: Vec::new(),
624            uri: uri.clone(),
625        });
626    }
627
628    let line_table = LineOffsetTable::new(content);
629    let char_offsets = CharOffsets::new(content);
630    let mut admitted_origins = HashSet::new();
631    let mut seen_route_keys = HashSet::new();
632    let mut routes = Vec::new();
633    let mut dependencies = Vec::new();
634
635    for entry in receiver.entries {
636        let Some((dep, route)) = build_dependency(
637            content,
638            &line_table,
639            &char_offsets,
640            policy,
641            instance_host,
642            &mut admitted_origins,
643            entry,
644        ) else {
645            continue;
646        };
647        if let Some((key, route_value)) = route
648            && seen_route_keys.insert(key.clone())
649        {
650            routes.push((key, route_value));
651        }
652        dependencies.push(dep);
653    }
654
655    Ok(GitlabCiParseResult {
656        dependencies,
657        routes,
658        uri: uri.clone(),
659    })
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use deps_core::Dependency;
666    use deps_core::net_policy::WorkspaceRegistryAccess;
667    use std::sync::{Arc, RwLock};
668
669    fn test_uri() -> Uri {
670        deps_core::test_util::test_uri("/repo/.gitlab-ci.yml")
671    }
672
673    fn ctx() -> (RegistryAccessPolicy, GitlabInstanceHost) {
674        let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly);
675        let instance_host = GitlabInstanceHost::new(
676            Arc::new(RwLock::new(None)),
677            Arc::new(RegistryAccessPolicy::new(
678                WorkspaceRegistryAccess::PublicOnly,
679            )),
680        );
681        (policy, instance_host)
682    }
683
684    fn ctx_with_instance_host(host: &str) -> (RegistryAccessPolicy, GitlabInstanceHost) {
685        let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly);
686        let instance_host = GitlabInstanceHost::new(
687            Arc::new(RwLock::new(Some(host.to_string()))),
688            Arc::new(RegistryAccessPolicy::new(
689                WorkspaceRegistryAccess::PublicOnly,
690            )),
691        );
692        (policy, instance_host)
693    }
694
695    fn slice(content: &str, range: Range) -> String {
696        let start_line = range.start.line as usize;
697        let end_line = range.end.line as usize;
698        if start_line == end_line {
699            let line = content.lines().nth(start_line).unwrap();
700            line[range.start.character as usize..range.end.character as usize].to_string()
701        } else {
702            panic!("multi-line ranges not supported by this test helper");
703        }
704    }
705
706    #[test]
707    fn test_project_ref_include_parses() {
708        let (policy, instance_host) = ctx_with_instance_host("gitlab.com");
709        let content = "include:\n  - project: org/proj\n    ref: v1.0.0\n";
710        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
711        assert_eq!(result.dependencies.len(), 1);
712        let dep = &result.dependencies[0];
713        assert_eq!(dep.kind, IncludeKind::Project);
714        assert_eq!(slice(content, dep.name_range), "org/proj");
715        assert_eq!(slice(content, dep.version_range().unwrap()), "v1.0.0");
716        assert_eq!(dep.pin, Some(PinStyle::Tag));
717        assert!(matches!(dep.host, HostRef::Literal(_)));
718        assert_eq!(dep.name(), "gitlab.com/org/proj");
719        assert!(matches!(
720            dep.source(),
721            DependencySource::AlternateRegistry { .. }
722        ));
723        assert_eq!(result.routes.len(), 1);
724    }
725
726    #[test]
727    fn test_project_ref_unresolved_host_when_instance_host_unset() {
728        let (policy, instance_host) = ctx();
729        let content = "include:\n  - project: org/proj\n    ref: v1.0.0\n";
730        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
731        let dep = &result.dependencies[0];
732        assert!(matches!(dep.host, HostRef::Unresolved(_)));
733        assert!(matches!(
734            dep.source(),
735            DependencySource::CustomRegistry { .. }
736        ));
737        assert_eq!(dep.name(), "org/proj");
738        assert!(result.routes.is_empty());
739    }
740
741    #[test]
742    fn test_project_sha_ref() {
743        let (policy, instance_host) = ctx();
744        let sha = "a".repeat(40);
745        let content = format!("include:\n  - project: org/proj\n    ref: {sha}\n");
746        let result = parse_gitlab_ci_yaml(&content, &test_uri(), &policy, &instance_host).unwrap();
747        assert_eq!(result.dependencies[0].pin, Some(PinStyle::Sha));
748    }
749
750    #[test]
751    fn test_project_branch_ref() {
752        let (policy, instance_host) = ctx();
753        let content = "include:\n  - project: org/proj\n    ref: main\n";
754        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
755        assert_eq!(result.dependencies[0].pin, Some(PinStyle::Branch));
756    }
757
758    #[test]
759    fn test_project_no_ref_has_no_pin() {
760        let (policy, instance_host) = ctx();
761        let content = "include:\n  - project: org/proj\n";
762        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
763        let dep = &result.dependencies[0];
764        assert!(dep.pin.is_none());
765        assert!(dep.version_range().is_none());
766    }
767
768    #[test]
769    fn test_component_include_parses_exact_version() {
770        let (policy, instance_host) = ctx();
771        let content = "include:\n  - component: gitlab.com/org/proj/comp@1.0.0\n";
772        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
773        let dep = &result.dependencies[0];
774        assert_eq!(dep.kind, IncludeKind::Component);
775        assert_eq!(slice(content, dep.name_range), "gitlab.com/org/proj/comp");
776        assert_eq!(slice(content, dep.version_range().unwrap()), "1.0.0");
777        assert_eq!(dep.name(), "gitlab.com/org/proj/comp");
778        assert_eq!(dep.project_path, "org/proj");
779        assert!(matches!(dep.host, HostRef::Literal(_)));
780    }
781
782    #[test]
783    fn test_component_ci_server_fqdn_unresolved_when_instance_host_unset() {
784        let (policy, instance_host) = ctx();
785        let content = "include:\n  - component: $CI_SERVER_FQDN/org/proj/comp@1.0\n";
786        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
787        let dep = &result.dependencies[0];
788        assert_eq!(dep.host, HostRef::Unresolved("$CI_SERVER_FQDN".to_string()));
789        assert!(matches!(
790            dep.source(),
791            DependencySource::CustomRegistry { .. }
792        ));
793    }
794
795    #[test]
796    fn test_component_ci_server_fqdn_resolves_when_instance_host_set() {
797        let (policy, instance_host) = ctx_with_instance_host("gitlab.mycorp.dev");
798        let content = "include:\n  - component: $CI_SERVER_FQDN/org/proj/comp@1.0\n";
799        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
800        let dep = &result.dependencies[0];
801        assert_eq!(dep.name(), "gitlab.mycorp.dev/org/proj/comp");
802    }
803
804    #[test]
805    fn test_component_missing_version_is_malformed() {
806        let (policy, instance_host) = ctx();
807        let content = "include:\n  - component: gitlab.com/org/proj/comp\n";
808        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
809        assert!(result.dependencies.is_empty());
810    }
811
812    #[test]
813    fn test_component_too_few_segments_is_malformed() {
814        let (policy, instance_host) = ctx();
815        let content = "include:\n  - component: gitlab.com/proj@1.0\n";
816        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
817        assert!(result.dependencies.is_empty());
818    }
819
820    #[test]
821    fn test_template_include_skipped() {
822        let (policy, instance_host) = ctx();
823        let content = "include:\n  - template: Security/SAST.gitlab-ci.yml\n";
824        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
825        assert!(result.dependencies.is_empty());
826    }
827
828    #[test]
829    fn test_remote_include_skipped() {
830        let (policy, instance_host) = ctx();
831        let content = "include:\n  - remote: 'https://example.com/ci.yml'\n";
832        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
833        assert!(result.dependencies.is_empty());
834    }
835
836    #[test]
837    fn test_local_key_include_skipped() {
838        let (policy, instance_host) = ctx();
839        let content = "include:\n  - local: 'ci/other.yml'\n";
840        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
841        assert!(result.dependencies.is_empty());
842    }
843
844    #[test]
845    fn test_bare_string_local_shorthand_skipped() {
846        let (policy, instance_host) = ctx();
847        let content = "include:\n  - 'ci/other.yml'\n";
848        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
849        assert!(result.dependencies.is_empty());
850    }
851
852    #[test]
853    fn test_single_mapping_include_form() {
854        let (policy, instance_host) = ctx();
855        let content = "include:\n  project: org/proj\n  ref: v1.0.0\n";
856        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
857        assert_eq!(result.dependencies.len(), 1);
858    }
859
860    #[test]
861    fn test_bare_string_include_shorthand_at_top_skipped() {
862        let (policy, instance_host) = ctx();
863        let content = "include: 'ci/other.yml'\n";
864        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
865        assert!(result.dependencies.is_empty());
866    }
867
868    #[test]
869    fn test_no_include_key_yields_empty() {
870        let (policy, instance_host) = ctx();
871        let content = "stages:\n  - build\n  - test\n";
872        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
873        assert!(result.dependencies.is_empty());
874    }
875
876    #[test]
877    fn test_image_and_services_never_parsed() {
878        let (policy, instance_host) = ctx();
879        let content = "image: alpine:3.18\nservices:\n  - postgres:14\ninclude:\n  - project: org/proj\n    ref: v1.0.0\n";
880        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
881        assert_eq!(result.dependencies.len(), 1);
882    }
883
884    #[test]
885    fn test_duplicate_includes_get_distinct_ranges() {
886        let (policy, instance_host) = ctx();
887        let content =
888            "include:\n  - project: org/a\n    ref: v1.0.0\n  - project: org/b\n    ref: v2.0.0\n";
889        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
890        assert_eq!(result.dependencies.len(), 2);
891        assert_ne!(
892            result.dependencies[0].version_range(),
893            result.dependencies[1].version_range()
894        );
895    }
896
897    #[test]
898    fn test_multi_document_include_in_second_document_found() {
899        let (policy, instance_host) = ctx();
900        let content = "spec:\n  inputs:\n    version:\n---\ninclude:\n  - project: org/proj\n    ref: v1.0.0\n";
901        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
902        assert_eq!(result.dependencies.len(), 1);
903    }
904
905    #[test]
906    fn test_multi_document_nested_key_in_second_document_not_mistaken_for_top_level() {
907        let (policy, instance_host) = ctx();
908        // Document 1 has no `include:` at its own top level; document 2's `include:` is
909        // nested inside `job:`, which must NOT be mistaken for a top-level key.
910        let content = "spec:\n  inputs:\n    version:\n---\njob:\n  include:\n    - project: org/proj\n      ref: v1.0.0\n";
911        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
912        assert!(result.dependencies.is_empty());
913    }
914
915    #[test]
916    fn test_deeply_nested_yaml_rejected_as_parse_error() {
917        let (policy, instance_host) = ctx();
918        let payload = format!("{}1", "- ".repeat(deps_core::MAX_YAML_NESTING_DEPTH + 1));
919        let result = parse_gitlab_ci_yaml(&payload, &test_uri(), &policy, &instance_host);
920        assert!(matches!(result, Err(DepsError::ParseError { .. })));
921    }
922
923    #[test]
924    fn test_invalid_yaml_returns_empty_result_not_error() {
925        let (policy, instance_host) = ctx();
926        let content = "include:\n  - [unterminated\n";
927        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
928        assert!(result.dependencies.is_empty());
929    }
930
931    #[test]
932    fn test_empty_content() {
933        let (policy, instance_host) = ctx();
934        let result = parse_gitlab_ci_yaml("", &test_uri(), &policy, &instance_host).unwrap();
935        assert!(result.dependencies.is_empty());
936    }
937
938    #[test]
939    fn test_host_fan_out_cap_admits_at_most_eight_distinct_hosts() {
940        let (policy, instance_host) = ctx();
941        let mut content = String::from("include:\n");
942        for i in 0..12 {
943            content.push_str(&format!(
944                "  - component: host{i}.example.com/org/proj/comp@1.0\n"
945            ));
946        }
947        let result = parse_gitlab_ci_yaml(&content, &test_uri(), &policy, &instance_host).unwrap();
948        assert_eq!(result.dependencies.len(), 12);
949        let resolved = result
950            .dependencies
951            .iter()
952            .filter(|d| matches!(d.host, HostRef::Literal(_)))
953            .count();
954        assert_eq!(resolved, MAX_HOSTS_PER_DOCUMENT);
955        // M-a (#466 review): a per-document cap refusal is `CapacityRefused`, not
956        // `Unresolved` — the host itself validated fine.
957        let capacity_refused = result
958            .dependencies
959            .iter()
960            .filter(|d| matches!(d.host, HostRef::CapacityRefused(_)))
961            .count();
962        assert_eq!(capacity_refused, 12 - MAX_HOSTS_PER_DOCUMENT);
963    }
964
965    #[test]
966    fn test_project_and_component_same_project_produce_distinct_names() {
967        // §3.1's documented residual collision case: same host + same project path but
968        // different `IncludeKind` (Tags vs Releases route) still coexist as distinct
969        // `PackageName`s as long as the component keeps its trailing component segment.
970        let (policy, instance_host) = ctx_with_instance_host("gitlab.com");
971        let content = "include:\n  - project: org/proj\n    ref: v1.0.0\n  - component: gitlab.com/org/proj/comp@1.0\n";
972        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
973        assert_eq!(result.dependencies.len(), 2);
974        assert_ne!(result.dependencies[0].name(), result.dependencies[1].name());
975    }
976
977    #[test]
978    fn test_routes_deduplicated_across_dependencies_sharing_a_host() {
979        let (policy, instance_host) = ctx_with_instance_host("gitlab.com");
980        let content =
981            "include:\n  - project: org/a\n    ref: v1.0.0\n  - project: org/b\n    ref: v2.0.0\n";
982        let result = parse_gitlab_ci_yaml(content, &test_uri(), &policy, &instance_host).unwrap();
983        assert_eq!(result.dependencies.len(), 2);
984        // Both `project:` includes resolve to the same instance host and the same
985        // (origin, Tags) route, so exactly one route entry is registered.
986        assert_eq!(result.routes.len(), 1);
987    }
988}