1use crate::config::{
33 AuthToken, ConfigFileCache, IndexTrust, RegistryIndex, RegistryIndexError, SourceReplacement,
34};
35use crate::types::{DependencySection, DependencySource, ParsedDependency};
36use deps_core::net_policy::RegistryAccessPolicy;
37use deps_core::{DepsError, Result};
38use std::any::Any;
39use std::collections::{HashMap, HashSet};
40use std::path::PathBuf;
41use std::sync::Arc;
42use toml_span::value::{Table, Value};
43use tower_lsp_server::ls_types::{Range, Uri};
44
45pub use deps_core::lsp_helpers::LineOffsetTable;
46
47#[derive(Debug, Clone)]
52pub struct ParseResult {
53 pub dependencies: Vec<ParsedDependency>,
55 pub workspace_root: Option<PathBuf>,
57 pub uri: Uri,
59 pub resolved_registries: Vec<(RegistryIndex, Option<AuthToken>)>,
72 pub blocked_registries: Vec<(Range, deps_core::net_policy::HostClass, String)>,
79}
80
81pub fn parse_cargo_toml(content: &str, doc_uri: &Uri) -> Result<ParseResult> {
106 parse_cargo_toml_with_context(content, doc_uri, &CargoParseContext::default())
107}
108
109#[derive(Clone)]
118pub struct CargoParseContext {
119 pub policy: Arc<RegistryAccessPolicy>,
121 pub config_cache: Arc<ConfigFileCache>,
124}
125
126impl Default for CargoParseContext {
127 fn default() -> Self {
128 Self {
129 policy: Arc::new(RegistryAccessPolicy::default()),
130 config_cache: Arc::new(ConfigFileCache::new()),
131 }
132 }
133}
134
135pub fn parse_cargo_toml_with_context(
148 content: &str,
149 doc_uri: &Uri,
150 ctx: &CargoParseContext,
151) -> Result<ParseResult> {
152 if let Err(depth) =
153 deps_core::check_toml_nesting_depth(content, deps_core::MAX_TOML_NESTING_DEPTH)
154 {
155 return Err(DepsError::ParseError {
156 file_type: "Cargo.toml".into(),
157 source: Box::new(std::io::Error::other(format!(
158 "array/table nesting depth {depth} exceeds maximum of {}",
159 deps_core::MAX_TOML_NESTING_DEPTH
160 ))),
161 });
162 }
163
164 let doc = toml_span::parse(content).map_err(|e| DepsError::ParseError {
165 file_type: "Cargo.toml".into(),
166 source: Box::new(std::io::Error::other(e.to_string())),
167 })?;
168
169 let line_table = LineOffsetTable::new(content);
170 let mut dependencies = Vec::new();
171
172 let root_table = doc.as_table().ok_or_else(|| DepsError::ParseError {
173 file_type: "Cargo.toml".into(),
174 source: Box::new(std::io::Error::other("root is not a table")),
175 })?;
176
177 parse_dependency_kind_tables(root_table, content, &line_table, &mut dependencies);
178
179 if let Some(target_val) = get_val(root_table, "target")
184 && let Some(target_table) = target_val.as_table()
185 {
186 for target_entry in target_table.values() {
187 if let Some(target_spec_table) = target_entry.as_table() {
188 parse_dependency_kind_tables(
189 target_spec_table,
190 content,
191 &line_table,
192 &mut dependencies,
193 );
194 }
195 }
196 }
197
198 if let Some(workspace_val) = get_val(root_table, "workspace")
200 && let Some(workspace_table) = workspace_val.as_table()
201 && let Some(workspace_deps_val) = get_val(workspace_table, "dependencies")
202 && let Some(workspace_deps) = workspace_deps_val.as_table()
203 {
204 dependencies.extend(parse_dependencies_section(
205 workspace_deps,
206 content,
207 &line_table,
208 DependencySection::WorkspaceDependencies,
209 ));
210 }
211
212 let discovery = discover_workspace(doc_uri)?;
213
214 let (resolved_registries, blocked_registries) =
215 resolve_alternate_registries(&mut dependencies, &discovery.config_paths, ctx);
216
217 Ok(ParseResult {
218 dependencies,
219 workspace_root: discovery.workspace_root,
220 uri: doc_uri.clone(),
221 blocked_registries,
222 resolved_registries,
223 })
224}
225
226fn get_val<'a>(table: &'a Table<'a>, key: &str) -> Option<&'a Value<'a>> {
227 table.get(key)
228}
229
230type AlternateRegistryResolution = (
234 Vec<(RegistryIndex, Option<AuthToken>)>,
235 Vec<(Range, deps_core::net_policy::HostClass, String)>,
236);
237
238fn resolve_alternate_registries(
264 dependencies: &mut [ParsedDependency],
265 workspace_config_paths: &[PathBuf],
266 ctx: &CargoParseContext,
267) -> AlternateRegistryResolution {
268 let raw_values: HashSet<String> = dependencies
269 .iter()
270 .filter_map(|dep| match &dep.source {
271 DependencySource::CustomRegistry { url } => Some(url.clone()),
272 _ => None,
273 })
274 .collect();
275
276 let mut aliases: HashSet<String> = HashSet::new();
277 let mut resolved_by_raw_value: HashMap<String, RegistryIndex> = HashMap::new();
280 let mut blocked_by_raw_value: HashMap<String, deps_core::net_policy::HostClass> =
283 HashMap::new();
284 let mut newly_resolved: Vec<(RegistryIndex, Option<AuthToken>)> = Vec::new();
285
286 for value in &raw_values {
287 match RegistryIndex::new(value, IndexTrust::WorkspaceDeclared, &ctx.policy) {
295 Ok(index) => {
296 resolved_by_raw_value.insert(value.clone(), index.clone());
297 newly_resolved.push((index, None));
298 }
299 Err(RegistryIndexError::BlockedHost { class }) => {
300 blocked_by_raw_value.insert(value.clone(), class);
301 }
302 Err(_) => {
303 aliases.insert(value.clone());
304 }
305 }
306 }
307
308 let cargo_home_path = crate::config::cargo_home_config_path();
309 let (config, source_replacement) = crate::config::resolve(
310 &aliases,
311 workspace_config_paths,
312 cargo_home_path.as_deref(),
313 &ctx.config_cache,
314 &ctx.policy,
315 );
316
317 for alias in &aliases {
318 if let Some(entry) = config.get(alias) {
319 resolved_by_raw_value.insert(alias.clone(), entry.index.clone());
320 newly_resolved.push((entry.index.clone(), entry.auth.clone()));
321 } else if let Some(class) = config.blocked_class(alias) {
322 blocked_by_raw_value.insert(alias.clone(), class);
323 } else {
324 let redacted = deps_core::net_policy::redact_userinfo(alias);
329 tracing::warn!(
330 alias = %redacted,
331 "registry alias did not resolve via the .cargo/config.toml \
332 hierarchy or $CARGO_HOME/config.toml; dependency stays unresolved"
333 );
334 }
335 }
336
337 if let SourceReplacement::SparseMirror { index, auth } = source_replacement {
342 newly_resolved.push((index.clone(), auth));
343 for dep in dependencies.iter_mut() {
344 if dep.source == DependencySource::Registry {
345 dep.source = DependencySource::AlternateRegistry {
346 index: index.as_str().to_string(),
347 mirrors_crates_io: true,
348 };
349 }
350 }
351 }
352
353 let mut blocked_registries = Vec::new();
354 for dep in dependencies.iter_mut() {
355 if let DependencySource::CustomRegistry { url } = &dep.source {
356 if let Some(index) = resolved_by_raw_value.get(url) {
357 dep.source = DependencySource::AlternateRegistry {
358 index: index.as_str().to_string(),
359 mirrors_crates_io: false,
360 };
361 } else if let Some(class) = blocked_by_raw_value.get(url) {
362 blocked_registries.push((dep.name_range, *class, url.clone()));
363 }
364 }
365 }
366
367 (newly_resolved, blocked_registries)
368}
369
370fn parse_dependency_kind_tables(
376 table: &Table<'_>,
377 content: &str,
378 line_table: &LineOffsetTable,
379 dependencies: &mut Vec<ParsedDependency>,
380) {
381 if let Some(deps_val) = get_val(table, "dependencies")
382 && let Some(deps) = deps_val.as_table()
383 {
384 dependencies.extend(parse_dependencies_section(
385 deps,
386 content,
387 line_table,
388 DependencySection::Dependencies,
389 ));
390 }
391
392 if let Some(dev_deps_val) = get_val(table, "dev-dependencies")
393 && let Some(dev_deps) = dev_deps_val.as_table()
394 {
395 dependencies.extend(parse_dependencies_section(
396 dev_deps,
397 content,
398 line_table,
399 DependencySection::DevDependencies,
400 ));
401 }
402
403 if let Some(build_deps_val) = get_val(table, "build-dependencies")
404 && let Some(build_deps) = build_deps_val.as_table()
405 {
406 dependencies.extend(parse_dependencies_section(
407 build_deps,
408 content,
409 line_table,
410 DependencySection::BuildDependencies,
411 ));
412 }
413}
414
415fn parse_dependencies_section(
417 table: &Table<'_>,
418 content: &str,
419 line_table: &LineOffsetTable,
420 section: DependencySection,
421) -> Vec<ParsedDependency> {
422 let mut deps = Vec::new();
423
424 for (key, value) in table {
425 let name = key.name.to_string();
426 let name_range = span_to_range(content, line_table, key.span);
427
428 let mut dep = ParsedDependency {
429 name: name.into(),
430 name_range,
431 version_req: None,
432 version_range: None,
433 features: Vec::new(),
434 features_range: None,
435 source: DependencySource::Registry,
436 section,
437 };
438
439 if let Some(s) = value.as_str() {
440 dep.version_req = Some(s.into());
442 dep.version_range = Some(span_to_range(content, line_table, value.span));
443 } else if let Some(t) = value.as_table() {
444 parse_table_dependency(&mut dep, t, content, line_table);
446 } else {
447 continue;
448 }
449
450 deps.push(dep);
451 }
452
453 deps
454}
455
456fn parse_table_dependency(
458 dep: &mut ParsedDependency,
459 table: &Table<'_>,
460 content: &str,
461 line_table: &LineOffsetTable,
462) {
463 let mut git_rev: Option<String> = None;
469
470 for (key, value) in table {
471 match key.name.as_ref() {
472 "version" => {
473 if let Some(s) = value.as_str() {
474 dep.version_req = Some(s.into());
475 dep.version_range = Some(span_to_range(content, line_table, value.span));
476 }
477 }
478 "features" => {
479 if let Some(arr) = value.as_array() {
480 dep.features = arr
481 .iter()
482 .filter_map(|v| v.as_str().map(String::from))
483 .collect();
484 dep.features_range = Some(span_to_range(content, line_table, value.span));
485 }
486 }
487 "workspace" if value.as_bool() == Some(true) => {
488 dep.source = DependencySource::Workspace;
489 }
490 "workspace" => {}
491 "git" => {
492 if let Some(url) = value.as_str() {
493 dep.source = DependencySource::Git {
494 url: url.to_string(),
495 rev: None,
496 };
497 }
498 }
499 "tag" | "branch" | "rev" => {
503 if let Some(rev) = value.as_str() {
504 git_rev = Some(rev.to_string());
505 }
506 }
507 "path" => {
508 if let Some(path) = value.as_str() {
509 dep.source = DependencySource::Path {
510 path: path.to_string(),
511 };
512 }
513 }
514 "registry" => {
525 if let Some(name) = value.as_str()
526 && name != "crates-io"
527 {
528 dep.source = DependencySource::CustomRegistry {
529 url: name.to_string(),
530 };
531 }
532 }
533 "registry-index" => {
538 if let Some(url) = value.as_str()
539 && !is_public_crates_io_index(url)
540 {
541 dep.source = DependencySource::CustomRegistry {
542 url: url.to_string(),
543 };
544 }
545 }
546 _ => {}
547 }
548 }
549
550 if let DependencySource::Git { rev, .. } = &mut dep.source {
551 *rev = git_rev;
552 }
553}
554
555fn is_public_crates_io_index(url: &str) -> bool {
558 matches!(
559 url,
560 "https://github.com/rust-lang/crates.io-index" | "sparse+https://index.crates.io/"
561 )
562}
563
564fn span_to_range(content: &str, line_table: &LineOffsetTable, span: toml_span::Span) -> Range {
566 let start = line_table.byte_offset_to_position(content, span.start);
567 let end = line_table.byte_offset_to_position(content, span.end);
568 Range::new(start, end)
569}
570
571pub(crate) const MAX_CONFIG_ANCESTOR_DEPTH: usize = 64;
580
581struct WorkspaceDiscovery {
583 workspace_root: Option<PathBuf>,
587 config_paths: Vec<PathBuf>,
591}
592
593fn discover_workspace(doc_uri: &Uri) -> Result<WorkspaceDiscovery> {
610 let path = doc_uri
611 .to_file_path()
612 .ok_or_else(|| DepsError::InvalidUri(format!("{doc_uri:?}")))?;
613
614 let mut workspace_root = None;
615 let mut config_paths = Vec::new();
616 let mut current = path.parent();
617 let mut depth = 0usize;
618
619 while let Some(dir) = current {
620 if depth >= MAX_CONFIG_ANCESTOR_DEPTH {
621 break;
622 }
623 depth += 1;
624
625 let config_candidate = dir.join(".cargo").join("config.toml");
626 if deps_core::fs_probe::is_file(&config_candidate) {
627 config_paths.push(config_candidate);
628 }
629
630 if workspace_root.is_none() {
631 let workspace_toml = dir.join("Cargo.toml");
632
633 if let Ok(metadata) = deps_core::fs_probe::metadata(&workspace_toml)
634 && metadata.is_file()
635 {
636 if metadata.len() > deps_core::MAX_CACHED_FILE_BYTES {
637 tracing::warn!(
638 path = %workspace_toml.display(),
639 len = metadata.len(),
640 cap = deps_core::MAX_CACHED_FILE_BYTES,
641 "skipping ancestor Cargo.toml during workspace root discovery: exceeds size cap"
642 );
643 } else {
644 match deps_core::fs_probe::read_to_string_capped(
645 &workspace_toml,
646 deps_core::MAX_CACHED_FILE_BYTES,
647 ) {
648 Ok(Some(content)) => {
649 if deps_core::check_toml_nesting_depth(
650 &content,
651 deps_core::MAX_TOML_NESTING_DEPTH,
652 )
653 .is_err()
654 {
655 tracing::warn!(
656 path = %workspace_toml.display(),
657 "skipping ancestor Cargo.toml during workspace root discovery: nesting depth exceeds maximum"
658 );
659 } else if let Ok(doc) = toml_span::parse(&content)
660 && doc
661 .as_table()
662 .and_then(|t| get_val(t, "workspace"))
663 .is_some()
664 {
665 workspace_root = Some(dir.to_path_buf());
666 }
667 }
668 Ok(None) => {
669 tracing::warn!(
674 path = %workspace_toml.display(),
675 cap = deps_core::MAX_CACHED_FILE_BYTES,
676 "skipping ancestor Cargo.toml during workspace root discovery: exceeds size cap on read"
677 );
678 }
679 Err(_) => {}
680 }
681 }
682 }
683 }
684
685 current = dir.parent();
686 }
687
688 Ok(WorkspaceDiscovery {
689 workspace_root,
690 config_paths,
691 })
692}
693
694pub struct CargoParser;
696
697impl deps_core::ParseResult for ParseResult {
699 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
700 self.dependencies
701 .iter()
702 .map(|d| d as &dyn deps_core::Dependency)
703 .collect()
704 }
705
706 fn workspace_root(&self) -> Option<&std::path::Path> {
707 self.workspace_root.as_deref()
708 }
709
710 fn uri(&self) -> &Uri {
711 &self.uri
712 }
713
714 fn blocked_registries(&self) -> Vec<(Range, deps_core::net_policy::HostClass, String)> {
715 self.blocked_registries.clone()
716 }
717
718 fn as_any(&self) -> &dyn Any {
719 self
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 use std::assert_matches;
728
729 fn test_url() -> Uri {
730 #[cfg(windows)]
731 let path = "C:/test/Cargo.toml";
732 #[cfg(not(windows))]
733 let path = "/test/Cargo.toml";
734 Uri::from_file_path(path).unwrap()
735 }
736
737 #[test]
738 fn test_parse_cargo_toml_rejects_excessive_nesting() {
739 let content = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
743 let result = parse_cargo_toml(&content, &test_url());
744 assert_matches!(
745 result,
746 Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.toml"
747 );
748 }
749
750 #[test]
751 fn test_find_workspace_root_rejects_non_file_uri() {
752 let uri: Uri = "https://example.com".parse().unwrap();
757 let result = parse_cargo_toml("[dependencies]\nserde = \"1.0\"", &uri);
758 assert!(
759 matches!(result, Err(DepsError::InvalidUri(_))),
760 "expected InvalidUri, got {result:?}"
761 );
762 }
763
764 #[test]
765 fn test_parse_inline_dependency() {
766 let toml = r#"[dependencies]
767serde = "1.0""#;
768 let result = parse_cargo_toml(toml, &test_url()).unwrap();
769 assert_eq!(result.dependencies.len(), 1);
770 assert_eq!(result.dependencies[0].name, "serde");
771 assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
772 assert_matches!(result.dependencies[0].source, DependencySource::Registry);
773 }
774
775 #[test]
776 fn test_parse_table_dependency() {
777 let toml = r#"[dependencies]
778serde = { version = "1.0", features = ["derive"] }"#;
779 let result = parse_cargo_toml(toml, &test_url()).unwrap();
780 assert_eq!(result.dependencies.len(), 1);
781 assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
782 assert_eq!(result.dependencies[0].features, vec!["derive"]);
783 }
784
785 #[test]
786 fn test_parse_workspace_inheritance() {
787 let toml = r"[dependencies]
788serde = { workspace = true }";
789 let result = parse_cargo_toml(toml, &test_url()).unwrap();
790 assert_eq!(result.dependencies.len(), 1);
791 assert_matches!(result.dependencies[0].source, DependencySource::Workspace);
792 }
793
794 #[test]
795 fn test_parse_git_dependency() {
796 let toml = r#"[dependencies]
797tower-lsp = { git = "https://github.com/ebkalderon/tower-lsp", branch = "main" }"#;
798 let result = parse_cargo_toml(toml, &test_url()).unwrap();
799 assert_eq!(result.dependencies.len(), 1);
800 match &result.dependencies[0].source {
801 DependencySource::Git { rev, .. } => assert_eq!(rev.as_deref(), Some("main")),
802 other => panic!("expected Git, got {other:?}"),
803 }
804 }
805
806 #[test]
807 fn test_parse_git_dependency_with_tag() {
808 let toml = r#"[dependencies]
809helix-core = { git = "https://github.com/helix-editor/helix", tag = "25.07.1" }"#;
810 let result = parse_cargo_toml(toml, &test_url()).unwrap();
811 assert_eq!(result.dependencies.len(), 1);
812 match &result.dependencies[0].source {
813 DependencySource::Git { rev, .. } => assert_eq!(rev.as_deref(), Some("25.07.1")),
814 other => panic!("expected Git, got {other:?}"),
815 }
816 }
817
818 #[test]
819 fn test_parse_git_dependency_with_rev() {
820 let toml = r#"[dependencies]
821example = { git = "https://github.com/example/example", rev = "abc123" }"#;
822 let result = parse_cargo_toml(toml, &test_url()).unwrap();
823 assert_eq!(result.dependencies.len(), 1);
824 match &result.dependencies[0].source {
825 DependencySource::Git { rev, .. } => assert_eq!(rev.as_deref(), Some("abc123")),
826 other => panic!("expected Git, got {other:?}"),
827 }
828 }
829
830 #[test]
831 fn test_parse_git_dependency_without_rev_stays_none() {
832 let toml = r#"[dependencies]
833example = { git = "https://github.com/example/example" }"#;
834 let result = parse_cargo_toml(toml, &test_url()).unwrap();
835 assert_eq!(result.dependencies.len(), 1);
836 match &result.dependencies[0].source {
837 DependencySource::Git { rev, .. } => assert!(rev.is_none()),
838 other => panic!("expected Git, got {other:?}"),
839 }
840 }
841
842 #[test]
843 fn test_parse_path_dependency() {
844 let toml = r#"[dependencies]
845local = { path = "../local" }"#;
846 let result = parse_cargo_toml(toml, &test_url()).unwrap();
847 assert_eq!(result.dependencies.len(), 1);
848 assert_matches!(result.dependencies[0].source, DependencySource::Path { .. });
849 }
850
851 #[test]
852 fn test_parse_custom_registry_dependency() {
853 let toml = r#"[dependencies]
854internal-crate = { version = "1.0", registry = "my-corp" }"#;
855 let result = parse_cargo_toml(toml, &test_url()).unwrap();
856 assert_eq!(result.dependencies.len(), 1);
857 match &result.dependencies[0].source {
858 DependencySource::CustomRegistry { url } => assert_eq!(url, "my-corp"),
859 other => panic!("expected CustomRegistry, got {other:?}"),
860 }
861 assert!(!result.dependencies[0].source.is_version_resolvable());
862 }
863
864 #[test]
865 fn test_parse_registry_crates_io_alias_stays_registry() {
866 let toml = r#"[dependencies]
867serde = { version = "1.0", registry = "crates-io" }"#;
868 let result = parse_cargo_toml(toml, &test_url()).unwrap();
869 assert_eq!(result.dependencies.len(), 1);
870 assert_eq!(result.dependencies[0].source, DependencySource::Registry);
871 assert!(result.dependencies[0].source.is_version_resolvable());
872 }
873
874 #[test]
875 fn test_parse_registry_index_custom_url() {
876 let toml = r#"[dependencies]
880internal-crate = { version = "1.0", registry-index = "https://gitlab.mycorp.com/registry-index" }"#;
881 let result = parse_cargo_toml(toml, &test_url()).unwrap();
882 assert_eq!(result.dependencies.len(), 1);
883 match &result.dependencies[0].source {
884 DependencySource::AlternateRegistry { index, .. } => {
885 assert_eq!(index, "https://gitlab.mycorp.com/registry-index");
886 }
887 other => panic!("expected AlternateRegistry, got {other:?}"),
888 }
889 assert_eq!(result.resolved_registries.len(), 1);
890 assert!(result.resolved_registries[0].1.is_none());
891 }
892
893 #[test]
894 fn test_parse_registry_index_invalid_url_stays_custom_registry() {
895 let toml = r#"[dependencies]
898internal-crate = { version = "1.0", registry-index = "http://insecure.mycorp.com/index" }"#;
899 let result = parse_cargo_toml(toml, &test_url()).unwrap();
900 assert_eq!(result.dependencies.len(), 1);
901 match &result.dependencies[0].source {
902 DependencySource::CustomRegistry { url } => {
903 assert_eq!(url, "http://insecure.mycorp.com/index");
904 }
905 other => panic!("expected CustomRegistry, got {other:?}"),
906 }
907 assert!(result.resolved_registries.is_empty());
908 }
909
910 #[test]
915 fn test_parse_registry_index_literal_blocked_by_policy_populates_blocked_registries() {
916 let toml = r#"[dependencies]
917internal-crate = { version = "1.0", registry-index = "https://169.254.169.254/index" }"#;
918 let ctx = CargoParseContext::default(); let result = parse_cargo_toml_with_context(toml, &test_url(), &ctx).unwrap();
920
921 assert_eq!(result.dependencies.len(), 1);
922 assert!(
923 matches!(
924 &result.dependencies[0].source,
925 DependencySource::CustomRegistry { url } if url == "https://169.254.169.254/index"
926 ),
927 "a blocked index must stay unresolved, not silently become AlternateRegistry"
928 );
929 assert_eq!(result.blocked_registries.len(), 1);
930 let (range, class, raw_value) = &result.blocked_registries[0];
931 assert_eq!(*range, result.dependencies[0].name_range);
932 assert_eq!(*class, deps_core::net_policy::HostClass::CloudMetadata);
933 assert_eq!(raw_value, "https://169.254.169.254/index");
934 }
935
936 #[test]
940 fn test_parse_custom_registry_alias_blocked_by_policy_populates_blocked_registries() {
941 let root = tempfile::tempdir().unwrap();
942 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
943 std::fs::write(
944 root.path().join(".cargo/config.toml"),
945 "[registries.my-corp]\nindex = \"https://169.254.169.254\"\n",
946 )
947 .unwrap();
948
949 let manifest_path = root.path().join("Cargo.toml");
950 let manifest_content =
951 "[dependencies]\ninternal-crate = { version = \"1.0\", registry = \"my-corp\" }\n";
952 std::fs::write(&manifest_path, manifest_content).unwrap();
953 let uri = Uri::from_file_path(&manifest_path).unwrap();
954
955 let ctx = CargoParseContext::default();
956 let result = parse_cargo_toml_with_context(manifest_content, &uri, &ctx).unwrap();
957
958 assert_eq!(result.blocked_registries.len(), 1);
959 let (range, class, raw_value) = &result.blocked_registries[0];
960 assert_eq!(*range, result.dependencies[0].name_range);
961 assert_eq!(*class, deps_core::net_policy::HostClass::CloudMetadata);
962 assert_eq!(raw_value, "my-corp");
963 }
964
965 #[test]
974 fn test_parse_registry_index_userinfo_alias_fallback_redacts_credential_in_log() {
975 let toml = r#"[dependencies]
976internal-crate = { version = "1.0", registry-index = "sparse+https://user:hunter2@index.crates.io/" }"#;
977
978 let log = deps_core::test_util::capture_tracing_output(|| {
979 let result = parse_cargo_toml(toml, &test_url()).unwrap();
980 assert_eq!(result.dependencies.len(), 1);
981 assert!(
982 matches!(
983 &result.dependencies[0].source,
984 DependencySource::CustomRegistry { url }
985 if url == "sparse+https://user:hunter2@index.crates.io/"
986 ),
987 "a userinfo-bearing index that fails alias resolution must stay unresolved"
988 );
989 });
990
991 assert!(
992 !log.contains("hunter2"),
993 "tracing output leaked the credential: {log:?}"
994 );
995 assert!(
996 !log.contains("user:"),
997 "tracing output leaked the username: {log:?}"
998 );
999 assert!(
1000 log.contains("index.crates.io"),
1001 "host should survive redaction: {log:?}"
1002 );
1003 }
1004
1005 #[test]
1013 fn test_parse_registry_index_env_collision_redacts_credential_in_log() {
1014 let toml = r#"[dependencies]
1015a = { version = "1.0", registry-index = "sparse+https://user:hunter2@index.mycorp.dev/" }
1016b = { version = "1.0", registry-index = "sparse+https://USER:hunter2@index.mycorp.dev/" }"#;
1017
1018 let log = deps_core::test_util::capture_tracing_output(|| {
1019 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1020 assert_eq!(result.dependencies.len(), 2);
1021 });
1022
1023 assert!(
1024 log.contains("two aliases derive the same"),
1025 "expected the env-collision WARN to fire: {log:?}"
1026 );
1027 assert!(
1028 !log.contains("hunter2"),
1029 "tracing output leaked the credential: {log:?}"
1030 );
1031 assert!(
1032 !log.to_lowercase().contains("user:"),
1033 "tracing output leaked the username: {log:?}"
1034 );
1035 assert!(
1036 log.contains("index.mycorp.dev"),
1037 "host should survive redaction: {log:?}"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_parse_custom_registry_alias_unresolved_without_config() {
1043 let toml = r#"[dependencies]
1048internal-crate = { version = "1.0", registry = "my-corp" }"#;
1049 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1050 assert_eq!(result.dependencies.len(), 1);
1051 match &result.dependencies[0].source {
1052 DependencySource::CustomRegistry { url } => assert_eq!(url, "my-corp"),
1053 other => panic!("expected CustomRegistry, got {other:?}"),
1054 }
1055 assert!(result.resolved_registries.is_empty());
1056 }
1057
1058 #[test]
1059 fn test_parse_custom_registry_alias_resolves_via_workspace_config() {
1060 let root = tempfile::tempdir().unwrap();
1061 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1062 std::fs::write(
1063 root.path().join(".cargo/config.toml"),
1064 "[registries.my-corp]\nindex = \"sparse+https://index.mycorp.dev\"\n",
1065 )
1066 .unwrap();
1067
1068 let manifest_path = root.path().join("Cargo.toml");
1069 std::fs::write(&manifest_path, "").unwrap();
1070 let uri = Uri::from_file_path(&manifest_path).unwrap();
1071
1072 let toml = r#"[dependencies]
1073internal-crate = { version = "1.0", registry = "my-corp" }"#;
1074 let result = parse_cargo_toml(toml, &uri).unwrap();
1075 assert_eq!(result.dependencies.len(), 1);
1076 match &result.dependencies[0].source {
1077 DependencySource::AlternateRegistry { index, .. } => {
1078 assert_eq!(index, "https://index.mycorp.dev/");
1079 }
1080 other => panic!("expected AlternateRegistry, got {other:?}"),
1081 }
1082 assert_eq!(result.resolved_registries.len(), 1);
1083 assert!(
1084 result.resolved_registries[0].1.is_none(),
1085 "workspace-sourced entry must never carry a token"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_parse_no_custom_registry_dependency_resolves_nothing() {
1091 let toml = r#"[dependencies]
1098serde = "1.0""#;
1099 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1100 assert!(result.resolved_registries.is_empty());
1101 }
1102
1103 #[test]
1108 fn test_parse_plain_dependency_rewritten_via_source_replace_with_mirror() {
1109 let root = tempfile::tempdir().unwrap();
1110 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1111 std::fs::write(
1112 root.path().join(".cargo/config.toml"),
1113 "[source.crates-io]\nreplace-with = \"my-mirror\"\n\
1114 [source.my-mirror]\nregistry = \"sparse+https://mirror.corp.example\"\n",
1115 )
1116 .unwrap();
1117
1118 let manifest_content = "[dependencies]\nserde = \"1.0\"\n";
1119 let manifest_path = root.path().join("Cargo.toml");
1120 std::fs::write(&manifest_path, manifest_content).unwrap();
1121 let uri = Uri::from_file_path(&manifest_path).unwrap();
1122
1123 let result = parse_cargo_toml(manifest_content, &uri).unwrap();
1124 assert_eq!(result.dependencies.len(), 1);
1125 match &result.dependencies[0].source {
1126 DependencySource::AlternateRegistry {
1127 index,
1128 mirrors_crates_io,
1129 } => {
1130 assert_eq!(index, "https://mirror.corp.example/");
1131 assert!(*mirrors_crates_io);
1132 }
1133 other => panic!("expected a resolved crates.io mirror, got {other:?}"),
1134 }
1135 assert_eq!(result.resolved_registries.len(), 1);
1136 }
1137
1138 #[test]
1142 fn test_parse_plain_dependency_unchanged_when_source_replace_with_is_vendored() {
1143 let root = tempfile::tempdir().unwrap();
1144 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1145 std::fs::write(
1146 root.path().join(".cargo/config.toml"),
1147 "[source.crates-io]\nreplace-with = \"vendored\"\n\
1148 [source.vendored]\ndirectory = \"vendor\"\n",
1149 )
1150 .unwrap();
1151
1152 let manifest_content = "[dependencies]\nserde = \"1.0\"\n";
1153 let manifest_path = root.path().join("Cargo.toml");
1154 std::fs::write(&manifest_path, manifest_content).unwrap();
1155 let uri = Uri::from_file_path(&manifest_path).unwrap();
1156
1157 let result = parse_cargo_toml(manifest_content, &uri).unwrap();
1158 assert_eq!(result.dependencies.len(), 1);
1159 assert_eq!(result.dependencies[0].source, DependencySource::Registry);
1160 assert!(result.resolved_registries.is_empty());
1161 }
1162
1163 #[test]
1164 fn test_parse_registry_index_public_crates_io_stays_registry() {
1165 let toml = r#"[dependencies]
1166serde = { version = "1.0", registry-index = "https://github.com/rust-lang/crates.io-index" }
1167serde_json = { version = "1.0", registry-index = "sparse+https://index.crates.io/" }"#;
1168 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1169 assert_eq!(result.dependencies.len(), 2);
1170 for dep in &result.dependencies {
1171 assert_eq!(dep.source, DependencySource::Registry);
1172 }
1173 }
1174
1175 #[test]
1176 fn test_parse_multiple_sections() {
1177 let toml = r#"
1178[dependencies]
1179serde = "1.0"
1180
1181[dev-dependencies]
1182insta = "1.0"
1183
1184[build-dependencies]
1185cc = "1.0"
1186"#;
1187 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1188 assert_eq!(result.dependencies.len(), 3);
1189
1190 assert_matches!(
1191 result.dependencies[0].section,
1192 DependencySection::Dependencies
1193 );
1194 assert_matches!(
1195 result.dependencies[1].section,
1196 DependencySection::DevDependencies
1197 );
1198 assert_matches!(
1199 result.dependencies[2].section,
1200 DependencySection::BuildDependencies
1201 );
1202 }
1203
1204 #[test]
1205 fn test_parse_target_cfg_dependencies() {
1206 let toml = "[target.'cfg(unix)'.dependencies]\nlibc = \"0.2\"";
1207 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1208 assert_eq!(result.dependencies.len(), 1);
1209
1210 let dep = &result.dependencies[0];
1211 assert_eq!(dep.name, "libc");
1212 assert_eq!(dep.version_req, Some("0.2".into()));
1213 assert_eq!(dep.source, DependencySource::Registry);
1214 assert_matches!(dep.section, DependencySection::Dependencies);
1215
1216 assert_eq!(dep.name_range.start.line, 1);
1218 assert_eq!(dep.name_range.start.character, 0);
1219 assert_eq!(dep.name_range.end.character, 4);
1220 }
1221
1222 #[test]
1223 fn test_parse_target_cfg_dev_dependencies() {
1224 let toml = "[target.'cfg(windows)'.dev-dependencies]\nwinapi = \"0.3\"";
1225 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1226 assert_eq!(result.dependencies.len(), 1);
1227
1228 let dep = &result.dependencies[0];
1229 assert_eq!(dep.name, "winapi");
1230 assert_eq!(dep.version_req, Some("0.3".into()));
1231 assert_matches!(dep.section, DependencySection::DevDependencies);
1232 }
1233
1234 #[test]
1235 fn test_parse_target_triple_build_dependencies() {
1236 let toml = "[target.x86_64-unknown-linux-gnu.build-dependencies]\ncc = \"1.0\"";
1237 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1238 assert_eq!(result.dependencies.len(), 1);
1239
1240 let dep = &result.dependencies[0];
1241 assert_eq!(dep.name, "cc");
1242 assert_eq!(dep.version_req, Some("1.0".into()));
1243 assert_matches!(dep.section, DependencySection::BuildDependencies);
1244 }
1245
1246 #[test]
1247 fn test_parse_target_dependencies_alongside_top_level() {
1248 let toml = r#"
1249[dependencies]
1250serde = "1.0"
1251
1252[target.'cfg(unix)'.dependencies]
1253libc = { version = "0.2", features = ["extra_traits"] }
1254
1255[target.'cfg(windows)'.dev-dependencies]
1256winapi = "0.3"
1257
1258[target.x86_64-unknown-linux-gnu.build-dependencies]
1259cc = "1.0"
1260"#;
1261 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1262 assert_eq!(result.dependencies.len(), 4);
1263
1264 let serde = result
1265 .dependencies
1266 .iter()
1267 .find(|d| d.name == "serde")
1268 .unwrap();
1269 assert_matches!(serde.section, DependencySection::Dependencies);
1270
1271 let libc = result
1272 .dependencies
1273 .iter()
1274 .find(|d| d.name == "libc")
1275 .unwrap();
1276 assert_eq!(libc.version_req, Some("0.2".into()));
1277 assert_eq!(libc.features, vec!["extra_traits"]);
1278 assert_matches!(libc.section, DependencySection::Dependencies);
1279
1280 let winapi = result
1281 .dependencies
1282 .iter()
1283 .find(|d| d.name == "winapi")
1284 .unwrap();
1285 assert_matches!(winapi.section, DependencySection::DevDependencies);
1286
1287 let cc = result.dependencies.iter().find(|d| d.name == "cc").unwrap();
1288 assert_matches!(cc.section, DependencySection::BuildDependencies);
1289 }
1290
1291 #[test]
1292 fn test_line_offset_table() {
1293 let content = "abc\ndef";
1294 let table = LineOffsetTable::new(content);
1295 let pos = table.byte_offset_to_position(content, 4);
1296 assert_eq!(pos.line, 1);
1297 assert_eq!(pos.character, 0);
1298 }
1299
1300 #[test]
1301 fn test_line_offset_table_unicode() {
1302 let content = "hello 世界\nworld";
1303 let table = LineOffsetTable::new(content);
1304 let world_offset = content.find("world").unwrap();
1305 let pos = table.byte_offset_to_position(content, world_offset);
1306 assert_eq!(pos.line, 1);
1307 assert_eq!(pos.character, 0);
1308 }
1309
1310 #[test]
1311 fn test_malformed_toml() {
1312 let toml = r#"[dependencies
1313serde = "1.0"#;
1314 let result = parse_cargo_toml(toml, &test_url());
1315 assert!(result.is_err());
1316 }
1317
1318 #[test]
1319 fn test_empty_dependencies() {
1320 let toml = r"[dependencies]";
1321 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1322 assert_eq!(result.dependencies.len(), 0);
1323 }
1324
1325 #[test]
1326 fn test_position_tracking() {
1327 let toml = r#"[dependencies]
1328serde = "1.0""#;
1329 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1330 let dep = &result.dependencies[0];
1331
1332 assert_eq!(dep.name, "serde");
1333 assert_eq!(dep.version_req, Some("1.0".into()));
1334
1335 assert_eq!(dep.name_range.start.line, 1);
1337 assert_eq!(dep.name_range.start.character, 0);
1339 assert_eq!(dep.name_range.end.character, 5);
1341 }
1342
1343 #[test]
1344 fn test_name_range_tracking() {
1345 let toml = r#"[dependencies]
1346serde = "1.0"
1347tokio = { version = "1.0", features = ["full"] }"#;
1348 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1349
1350 for dep in &result.dependencies {
1351 let is_default = dep.name_range.start.line == 0
1353 && dep.name_range.start.character == 0
1354 && dep.name_range.end.line == 0
1355 && dep.name_range.end.character == 0;
1356 assert!(
1357 !is_default,
1358 "name_range should not be default for {}",
1359 dep.name
1360 );
1361 }
1362 }
1363
1364 #[test]
1365 fn test_parse_workspace_dependencies() {
1366 let toml = r#"
1367[workspace]
1368members = ["crates/*"]
1369
1370[workspace.dependencies]
1371serde = "1.0"
1372tokio = { version = "1.0", features = ["full"] }
1373"#;
1374 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1375 assert_eq!(result.dependencies.len(), 2);
1376
1377 for dep in &result.dependencies {
1378 assert_matches!(dep.section, DependencySection::WorkspaceDependencies);
1379 }
1380
1381 let serde = result.dependencies.iter().find(|d| d.name == "serde");
1382 assert!(serde.is_some());
1383 let serde = serde.unwrap();
1384 assert_eq!(serde.version_req, Some("1.0".into()));
1385 assert!(
1387 serde.version_range.is_some(),
1388 "version_range should be set for serde"
1389 );
1390
1391 let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
1392 assert!(tokio.is_some());
1393 let tokio = tokio.unwrap();
1394 assert_eq!(tokio.version_req, Some("1.0".into()));
1395 assert_eq!(tokio.features, vec!["full"]);
1396 assert!(
1398 tokio.version_range.is_some(),
1399 "version_range should be set for tokio"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_parse_workspace_and_regular_dependencies() {
1405 let toml = r#"
1406[workspace]
1407members = ["crates/*"]
1408
1409[workspace.dependencies]
1410serde = "1.0"
1411
1412[dependencies]
1413tokio = "1.0"
1414"#;
1415 let result = parse_cargo_toml(toml, &test_url()).unwrap();
1416 assert_eq!(result.dependencies.len(), 2);
1417
1418 let serde = result.dependencies.iter().find(|d| d.name == "serde");
1419 assert!(serde.is_some());
1420 assert_matches!(
1421 serde.unwrap().section,
1422 DependencySection::WorkspaceDependencies
1423 );
1424
1425 let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
1426 assert!(tokio.is_some());
1427 assert_matches!(tokio.unwrap().section, DependencySection::Dependencies);
1428 }
1429
1430 #[test]
1431 fn test_find_workspace_root_skips_over_depth_ancestor() {
1432 let root = tempfile::tempdir().unwrap();
1440 let workspace_dir = root.path().join("workspace");
1441 let mid_dir = workspace_dir.join("mid");
1442 let pkg_dir = mid_dir.join("pkg");
1443 std::fs::create_dir_all(&pkg_dir).unwrap();
1444
1445 std::fs::write(
1446 workspace_dir.join("Cargo.toml"),
1447 "[workspace]\nmembers = [\"mid/pkg\"]\n",
1448 )
1449 .unwrap();
1450
1451 let malicious = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
1452 std::fs::write(mid_dir.join("Cargo.toml"), malicious).unwrap();
1453
1454 let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1455 let opened_path = pkg_dir.join("Cargo.toml");
1456 std::fs::write(&opened_path, opened_content).unwrap();
1457
1458 let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1459 let result = parse_cargo_toml(opened_content, &doc_uri).unwrap();
1460
1461 assert_eq!(result.dependencies.len(), 1);
1462 assert_eq!(result.workspace_root, Some(workspace_dir));
1463 }
1464
1465 #[test]
1476 fn test_discover_workspace_config_above_workspace_root_still_resolves() {
1477 let root = tempfile::tempdir().unwrap();
1478 let workspace_dir = root.path().join("workspace");
1479 std::fs::create_dir_all(&workspace_dir).unwrap();
1480 std::fs::write(
1481 workspace_dir.join("Cargo.toml"),
1482 "[workspace]\nmembers = [\"pkg\"]\n",
1483 )
1484 .unwrap();
1485
1486 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1489 std::fs::write(
1490 root.path().join(".cargo/config.toml"),
1491 "[registries.above-root]\nindex = \"sparse+https://above-root.example\"\n",
1492 )
1493 .unwrap();
1494
1495 let pkg_dir = workspace_dir.join("pkg");
1496 std::fs::create_dir_all(&pkg_dir).unwrap();
1497 let manifest_content =
1498 "[dependencies]\ninternal-crate = { version = \"1.0\", registry = \"above-root\" }\n";
1499 let manifest_path = pkg_dir.join("Cargo.toml");
1500 std::fs::write(&manifest_path, manifest_content).unwrap();
1501
1502 let doc_uri = Uri::from_file_path(&manifest_path).unwrap();
1503 let result = parse_cargo_toml(manifest_content, &doc_uri).unwrap();
1504
1505 assert_eq!(result.workspace_root, Some(workspace_dir));
1506 match &result.dependencies[0].source {
1507 DependencySource::AlternateRegistry { index, .. } => {
1508 assert_eq!(index, "https://above-root.example/");
1509 }
1510 other => panic!(
1511 "expected the above-workspace-root config to resolve the alias, got {other:?}"
1512 ),
1513 }
1514 }
1515
1516 #[test]
1520 fn test_discover_workspace_stops_at_max_ancestor_depth() {
1521 let root = tempfile::tempdir().unwrap();
1522
1523 let mut current = root.path().to_path_buf();
1527 for i in 0..(MAX_CONFIG_ANCESTOR_DEPTH + 5) {
1528 current = current.join(format!("d{i}"));
1529 }
1530 std::fs::create_dir_all(¤t).unwrap();
1531
1532 std::fs::write(
1535 root.path().join("Cargo.toml"),
1536 "[workspace]\nmembers = [\"*\"]\n",
1537 )
1538 .unwrap();
1539
1540 let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1541 let opened_path = current.join("Cargo.toml");
1542 std::fs::write(&opened_path, opened_content).unwrap();
1543
1544 let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1545 let result = parse_cargo_toml(opened_content, &doc_uri).unwrap();
1546
1547 assert_eq!(
1548 result.workspace_root, None,
1549 "a workspace root beyond MAX_CONFIG_ANCESTOR_DEPTH must not be found"
1550 );
1551 }
1552
1553 #[test]
1562 fn test_discover_workspace_skips_oversized_ancestor_cargo_toml() {
1563 let root = tempfile::tempdir().unwrap();
1564
1565 let mut current = root.path().to_path_buf();
1570 for i in 0..(MAX_CONFIG_ANCESTOR_DEPTH + 5) {
1571 current = current.join(format!("d{i}"));
1572 }
1573 std::fs::create_dir_all(¤t).unwrap();
1574
1575 let oversized_manifest = current
1579 .parent()
1580 .unwrap()
1581 .parent()
1582 .unwrap()
1583 .join("Cargo.toml");
1584 let file = std::fs::File::create(&oversized_manifest).unwrap();
1585 file.set_len(deps_core::MAX_CACHED_FILE_BYTES + 1).unwrap();
1586 drop(file);
1587
1588 let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1589 let opened_path = current.join("Cargo.toml");
1590 std::fs::write(&opened_path, opened_content).unwrap();
1591 let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1592
1593 let (_, reads_before) = deps_core::fs_probe::snapshot();
1594 let discovery = discover_workspace(&doc_uri).unwrap();
1595 let (_, reads_after) = deps_core::fs_probe::snapshot();
1596
1597 assert_eq!(
1598 discovery.workspace_root, None,
1599 "an oversized ancestor Cargo.toml must never be treated as the workspace root"
1600 );
1601 assert_eq!(
1602 reads_after - reads_before,
1603 1,
1604 "expected exactly one read: the opened document's own directory Cargo.toml — the \
1605 oversized ancestor two levels up must be rejected by the stat-based pre-filter \
1606 without ever being opened for a read"
1607 );
1608 }
1609
1610 #[test]
1619 fn test_discover_workspace_stats_at_most_two_per_ancestor() {
1620 let root = tempfile::tempdir().unwrap();
1621 let mut current = root.path().to_path_buf();
1622 for i in 0..(MAX_CONFIG_ANCESTOR_DEPTH + 5) {
1623 current = current.join(format!("d{i}"));
1624 }
1625 std::fs::create_dir_all(¤t).unwrap();
1626
1627 let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1628 let opened_path = current.join("Cargo.toml");
1629 std::fs::write(&opened_path, opened_content).unwrap();
1630 let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1631
1632 let (stats_before, _) = deps_core::fs_probe::snapshot();
1638 let discovery = discover_workspace(&doc_uri).unwrap();
1639 let (stats_after, _) = deps_core::fs_probe::snapshot();
1640
1641 assert_eq!(discovery.workspace_root, None);
1642 assert_eq!(
1643 stats_after - stats_before,
1644 2 * MAX_CONFIG_ANCESTOR_DEPTH,
1645 "expected exactly two stats per ancestor for all MAX_CONFIG_ANCESTOR_DEPTH levels"
1646 );
1647 }
1648}