1use 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
45const MAX_HOSTS_PER_DOCUMENT: usize = 8;
49
50const CI_SERVER_FQDN: &str = "$CI_SERVER_FQDN";
53
54#[derive(Clone, Copy, PartialEq, Eq)]
56enum FrameKind {
57 Mapping,
58 Sequence,
59}
60
61#[derive(Clone, Copy, PartialEq, Eq)]
63enum FrameRole {
64 Root,
66 IncludeValue,
68 IncludeEntry,
73 Irrelevant,
76}
77
78#[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
114type RawField = (String, TScalarStyle, usize);
117
118#[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 awaiting_key: bool,
135 pending_key: PendingKey,
137 entry: RawEntry,
139}
140
141struct 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 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 Event::Alias(_) => self.consume_pending_value(),
256 Event::DocumentStart | Event::DocumentEnd => {
257 self.stack.clear();
261 }
262 Event::Nothing | Event::StreamStart | Event::StreamEnd => {}
263 }
264 }
265}
266
267fn 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
280fn 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 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 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; 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 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
556pub 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 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 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 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 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 assert_eq!(result.routes.len(), 1);
987 }
988}