1use std::collections::{BTreeMap, BTreeSet};
8use std::error::Error;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker};
15
16const EXACT_CONFIDENCE: f32 = 1.0;
17const STATIC_TEXT_CONFIDENCE: f32 = 0.95;
18const PRESENCE_CONFIDENCE: f32 = 0.9;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum PackageEcosystem {
25 Npm,
27 Python,
29 Cargo,
31 Go,
33 Maven,
35 Gradle,
37 NuGet,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum DependencyScope {
46 Runtime,
48 Dev,
50 Test,
52 Build,
54 Peer,
56 Optional,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct PackageEvidenceLine {
63 pub line: u32,
65 #[serde(skip)]
67 pub text: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct PackageCoordinate {
73 pub ecosystem: PackageEcosystem,
75 pub name: String,
77 pub version: Option<String>,
79 pub source_path: String,
81 pub evidence: PackageEvidenceLine,
83 pub confidence: f32,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct PackageDependency {
90 pub ecosystem: PackageEcosystem,
92 pub name: String,
94 pub version_or_range: Option<String>,
96 pub scope: DependencyScope,
98 pub optional: bool,
100 pub condition: Option<String>,
102 pub source_path: String,
104 pub evidence: PackageEvidenceLine,
106 pub confidence: f32,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct PackageManifestValue {
113 pub value: String,
115 pub source_path: String,
117 pub evidence: PackageEvidenceLine,
119 pub confidence: f32,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct LockfileMetadata {
126 pub ecosystem: PackageEcosystem,
128 pub package_manager: String,
130 pub format_version: Option<String>,
132 pub source_path: String,
134 pub evidence: PackageEvidenceLine,
136 pub confidence: f32,
138}
139
140#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
142pub struct PackageManifest {
143 pub packages: Vec<PackageCoordinate>,
145 pub dependencies: Vec<PackageDependency>,
147 pub workspace_members: Vec<PackageManifestValue>,
149 pub exports: Vec<PackageManifestValue>,
151 pub features: Vec<PackageManifestValue>,
153 pub lockfiles: Vec<LockfileMetadata>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum PackageManifestError {
161 UnsupportedPath(String),
163 Malformed {
165 path: String,
167 message: String,
169 },
170 LimitExceeded(ExtractionLimitExceeded),
172}
173
174impl fmt::Display for PackageManifestError {
175 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176 match self {
177 Self::UnsupportedPath(path) => {
178 write!(formatter, "unsupported package manifest path `{path}`")
179 }
180 Self::Malformed { path, message } => {
181 write!(formatter, "malformed package manifest `{path}`: {message}")
182 }
183 Self::LimitExceeded(error) => error.fmt(formatter),
184 }
185 }
186}
187
188impl Error for PackageManifestError {
189 fn source(&self) -> Option<&(dyn Error + 'static)> {
190 match self {
191 Self::LimitExceeded(error) => Some(error),
192 Self::UnsupportedPath(_) | Self::Malformed { .. } => None,
193 }
194 }
195}
196
197impl From<ExtractionLimitExceeded> for PackageManifestError {
198 fn from(error: ExtractionLimitExceeded) -> Self {
199 Self::LimitExceeded(error)
200 }
201}
202
203pub fn extract_package_manifest(
218 relative_path: &str,
219 content: &str,
220) -> Result<PackageManifest, PackageManifestError> {
221 let mut tracker = ExtractionTracker::new(
222 relative_path,
223 "code-system-graph.packages",
224 &ExtractionBudgets::default(),
225 );
226 extract_package_manifest_with_tracker(relative_path, content, &mut tracker)
227}
228
229pub fn extract_package_manifest_with_tracker(
235 relative_path: &str,
236 content: &str,
237 tracker: &mut ExtractionTracker,
238) -> Result<PackageManifest, PackageManifestError> {
239 tracker.check_input_bytes(u64::try_from(content.len()).unwrap_or(u64::MAX))?;
240 validate_relative_path(relative_path)?;
241 tracker.charge_portable_path(&relative_path.replace('\\', "/"))?;
242 let file_name = relative_path.rsplit('/').next().unwrap_or(relative_path);
243 let lower_name = file_name.to_ascii_lowercase();
244 if std::path::Path::new(&lower_name)
245 .extension()
246 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
247 {
248 crate::graphql_contracts::precheck_json_structure(content, tracker)?;
249 } else {
250 precheck_package_text(content, tracker)?;
251 }
252
253 let result = match lower_name.as_str() {
254 "package.json" => parse_package_json(relative_path, content, tracker),
255 "package-lock.json" | "npm-shrinkwrap.json" => {
256 parse_npm_lockfile(relative_path, content, tracker)
257 }
258 "pnpm-lock.yaml" => parse_pnpm_lockfile(relative_path, content, tracker),
259 "yarn.lock" => parse_yarn_lockfile(relative_path, content, tracker),
260 "pyproject.toml" => parse_pyproject(relative_path, content, tracker),
261 "cargo.toml" => parse_cargo_manifest(relative_path, content, tracker),
262 "cargo.lock" => parse_cargo_lockfile(relative_path, content, tracker),
263 "go.mod" => parse_go_mod(relative_path, content, tracker),
264 "go.work" => parse_go_work(relative_path, content, tracker),
265 "pom.xml" => parse_maven(relative_path, content, tracker),
266 "build.gradle" | "build.gradle.kts" => parse_gradle(relative_path, content, tracker),
267 "packages.config" => parse_packages_config(relative_path, content, tracker),
268 _ if lower_name.starts_with("requirements")
269 && std::path::Path::new(&lower_name)
270 .extension()
271 .is_some_and(|extension| extension.eq_ignore_ascii_case("txt")) =>
272 {
273 parse_requirements(relative_path, content, tracker)
274 }
275 _ if lower_name.ends_with(".csproj") => parse_csproj(relative_path, content, tracker),
276 _ => Err(PackageManifestError::UnsupportedPath(
277 relative_path.to_owned(),
278 )),
279 };
280 if matches!(result, Err(PackageManifestError::LimitExceeded(_))) {
281 return result;
282 }
283 tracker.check_structured_time()?;
284 let result = result?;
285
286 let result = finalize(result);
287 tracker.check_structured_time()?;
288 Ok(result)
289}
290
291fn precheck_package_text(
292 content: &str,
293 tracker: &mut ExtractionTracker,
294) -> Result<(), ExtractionLimitExceeded> {
295 let bytes = content.as_bytes();
296 let mut cursor = 0_usize;
297 let mut accumulated = 0_u64;
298 while cursor < bytes.len() {
299 if cursor.is_multiple_of(1_024) {
300 tracker.check_structured_time()?;
301 }
302 let byte = bytes[cursor];
303 if matches!(byte, b'"' | b'\'') {
304 tracker.charge_work(1)?;
305 let delimiter = byte;
306 cursor = cursor.saturating_add(1);
307 let start = cursor;
308 let mut escaped = false;
309 while cursor < bytes.len() {
310 let byte = bytes[cursor];
311 if escaped {
312 escaped = false;
313 } else if byte == b'\\' {
314 escaped = true;
315 } else if byte == delimiter {
316 break;
317 }
318 cursor = cursor.saturating_add(1);
319 if cursor.is_multiple_of(1_024) {
320 tracker.check_structured_time()?;
321 }
322 }
323 let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
324 tracker.check_string_bytes(observed)?;
325 accumulated = accumulated.saturating_add(observed);
326 tracker.check_accumulated_string_bytes(accumulated)?;
327 cursor = cursor.saturating_add(1);
328 continue;
329 }
330 if byte == b'_' || byte.is_ascii_alphanumeric() || matches!(byte, b'@' | b'.' | b'-') {
331 let start = cursor;
332 cursor = cursor.saturating_add(1);
333 while cursor < bytes.len()
334 && (bytes[cursor].is_ascii_alphanumeric()
335 || matches!(bytes[cursor], b'_' | b'@' | b'.' | b'-' | b'/' | b':'))
336 {
337 cursor = cursor.saturating_add(1);
338 }
339 let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
340 tracker.charge_work(1)?;
341 tracker.check_identifier_bytes(observed)?;
342 accumulated = accumulated.saturating_add(observed);
343 tracker.check_accumulated_string_bytes(accumulated)?;
344 continue;
345 }
346 if matches!(
347 byte,
348 b'{' | b'}' | b'[' | b']' | b'(' | b')' | b':' | b'=' | b'<'
349 ) {
350 tracker.charge_work(1)?;
351 }
352 cursor = cursor.saturating_add(1);
353 }
354 Ok(())
355}
356
357fn push_package(
358 output: &mut Vec<PackageCoordinate>,
359 package: PackageCoordinate,
360 tracker: &mut ExtractionTracker,
361) -> Result<(), ExtractionLimitExceeded> {
362 tracker.charge_observation(1)?;
363 tracker.charge_identifier(&package.name)?;
364 if let Some(version) = &package.version {
365 tracker.charge_string(version)?;
366 }
367 tracker.charge_portable_path(&package.source_path)?;
368 output.push(package);
369 Ok(())
370}
371
372fn push_dependency(
373 output: &mut Vec<PackageDependency>,
374 dependency: PackageDependency,
375 tracker: &mut ExtractionTracker,
376) -> Result<(), ExtractionLimitExceeded> {
377 tracker.charge_observation(1)?;
378 tracker.charge_identifier(&dependency.name)?;
379 if let Some(version) = &dependency.version_or_range {
380 tracker.charge_string(version)?;
381 }
382 if let Some(condition) = &dependency.condition {
383 tracker.charge_string(condition)?;
384 }
385 tracker.charge_portable_path(&dependency.source_path)?;
386 output.push(dependency);
387 Ok(())
388}
389
390fn push_value(
391 output: &mut Vec<PackageManifestValue>,
392 value: PackageManifestValue,
393 tracker: &mut ExtractionTracker,
394) -> Result<(), ExtractionLimitExceeded> {
395 tracker.charge_observation(1)?;
396 tracker.charge_string(&value.value)?;
397 tracker.charge_portable_path(&value.source_path)?;
398 output.push(value);
399 Ok(())
400}
401
402fn push_lockfile(
403 output: &mut Vec<LockfileMetadata>,
404 lockfile: LockfileMetadata,
405 tracker: &mut ExtractionTracker,
406) -> Result<(), ExtractionLimitExceeded> {
407 tracker.charge_observation(1)?;
408 tracker.charge_identifier(&lockfile.package_manager)?;
409 if let Some(version) = &lockfile.format_version {
410 tracker.charge_string(version)?;
411 }
412 tracker.charge_portable_path(&lockfile.source_path)?;
413 output.push(lockfile);
414 Ok(())
415}
416
417fn validate_relative_path(path: &str) -> Result<(), PackageManifestError> {
418 let normalized = path.replace('\\', "/");
419 let has_parent = normalized.split('/').any(|part| part == "..");
420 let windows_absolute = normalized
421 .as_bytes()
422 .get(1)
423 .is_some_and(|byte| *byte == b':');
424 if path.is_empty() || normalized.starts_with('/') || windows_absolute || has_parent {
425 return Err(PackageManifestError::UnsupportedPath(path.to_owned()));
426 }
427 Ok(())
428}
429
430fn malformed(path: &str, message: impl Into<String>) -> PackageManifestError {
431 PackageManifestError::Malformed {
432 path: path.to_owned(),
433 message: message.into(),
434 }
435}
436
437fn finalize(mut manifest: PackageManifest) -> PackageManifest {
438 manifest.packages.sort_by(|left, right| {
439 (
440 left.ecosystem,
441 left.name.as_str(),
442 left.version.as_deref(),
443 left.evidence.line,
444 )
445 .cmp(&(
446 right.ecosystem,
447 right.name.as_str(),
448 right.version.as_deref(),
449 right.evidence.line,
450 ))
451 });
452 manifest.dependencies.sort_by(|left, right| {
453 (
454 left.ecosystem,
455 left.name.as_str(),
456 left.scope,
457 left.condition.as_deref(),
458 left.version_or_range.as_deref(),
459 left.evidence.line,
460 )
461 .cmp(&(
462 right.ecosystem,
463 right.name.as_str(),
464 right.scope,
465 right.condition.as_deref(),
466 right.version_or_range.as_deref(),
467 right.evidence.line,
468 ))
469 });
470 sort_values(&mut manifest.workspace_members);
471 sort_values(&mut manifest.exports);
472 sort_values(&mut manifest.features);
473 manifest.lockfiles.sort_by(|left, right| {
474 (
475 left.ecosystem,
476 left.package_manager.as_str(),
477 left.source_path.as_str(),
478 )
479 .cmp(&(
480 right.ecosystem,
481 right.package_manager.as_str(),
482 right.source_path.as_str(),
483 ))
484 });
485 manifest
486}
487
488fn sort_values(values: &mut [PackageManifestValue]) {
489 values.sort_by(|left, right| {
490 (left.value.as_str(), left.evidence.line).cmp(&(right.value.as_str(), right.evidence.line))
491 });
492}
493
494fn evidence_at(_content: &str, line: usize) -> PackageEvidenceLine {
495 PackageEvidenceLine {
496 line: u32::try_from(line.max(1)).unwrap_or(u32::MAX),
497 text: String::new(),
498 }
499}
500
501fn evidence_for(_content: &str, start_line: usize, _token: &str) -> PackageEvidenceLine {
502 evidence_at("", start_line)
505}
506
507fn value_fact(
508 path: &str,
509 content: &str,
510 value: String,
511 start_line: usize,
512 token: &str,
513) -> PackageManifestValue {
514 PackageManifestValue {
515 value,
516 source_path: path.to_owned(),
517 evidence: evidence_for(content, start_line, token),
518 confidence: EXACT_CONFIDENCE,
519 }
520}
521
522fn value_fact_at(path: &str, value: String, line: usize) -> PackageManifestValue {
523 PackageManifestValue {
524 value,
525 source_path: path.to_owned(),
526 evidence: evidence_at("", line),
527 confidence: EXACT_CONFIDENCE,
528 }
529}
530
531fn package(
532 ecosystem: PackageEcosystem,
533 name: String,
534 version: Option<String>,
535 path: &str,
536 evidence: PackageEvidenceLine,
537) -> PackageCoordinate {
538 PackageCoordinate {
539 ecosystem,
540 name,
541 version,
542 source_path: path.to_owned(),
543 evidence,
544 confidence: EXACT_CONFIDENCE,
545 }
546}
547
548struct DependencyInput<'a> {
549 ecosystem: PackageEcosystem,
550 name: String,
551 version: Option<String>,
552 scope: DependencyScope,
553 optional: bool,
554 condition: Option<String>,
555 path: &'a str,
556 evidence: PackageEvidenceLine,
557 confidence: f32,
558}
559
560fn dependency(input: DependencyInput<'_>) -> PackageDependency {
561 PackageDependency {
562 ecosystem: input.ecosystem,
563 name: input.name,
564 version_or_range: input.version,
565 scope: input.scope,
566 optional: input.optional,
567 condition: input.condition,
568 source_path: input.path.to_owned(),
569 evidence: input.evidence,
570 confidence: input.confidence,
571 }
572}
573
574fn json_string_lines(content: &str) -> BTreeMap<String, Vec<usize>> {
575 let bytes = content.as_bytes();
576 let mut lines = BTreeMap::<String, Vec<usize>>::new();
577 let mut cursor = 0_usize;
578 let mut line = 1_usize;
579 while cursor < bytes.len() {
580 if bytes[cursor] == b'\n' {
581 line = line.saturating_add(1);
582 cursor = cursor.saturating_add(1);
583 continue;
584 }
585 if bytes[cursor] != b'"' {
586 cursor = cursor.saturating_add(1);
587 continue;
588 }
589 let start = cursor;
590 let source_line = line;
591 cursor = cursor.saturating_add(1);
592 let mut escaped = false;
593 while cursor < bytes.len() {
594 let byte = bytes[cursor];
595 if escaped {
596 escaped = false;
597 } else if byte == b'\\' {
598 escaped = true;
599 } else if byte == b'"' {
600 break;
601 }
602 cursor = cursor.saturating_add(1);
603 }
604 if cursor < bytes.len()
605 && let Some(raw) = content.get(start..=cursor)
606 && let Ok(value) = serde_json::from_str::<String>(raw)
607 {
608 lines.entry(value).or_default().push(source_line);
609 }
610 cursor = cursor.saturating_add(1);
611 }
612 lines
613}
614
615fn json_string_line(lines: &BTreeMap<String, Vec<usize>>, value: &str, minimum: usize) -> usize {
616 lines
617 .get(value)
618 .and_then(|values| values.iter().copied().find(|line| *line >= minimum))
619 .unwrap_or(minimum)
620}
621
622fn parse_package_json(
623 path: &str,
624 content: &str,
625 tracker: &mut ExtractionTracker,
626) -> Result<PackageManifest, PackageManifestError> {
627 let root: Value =
628 serde_json::from_str(content).map_err(|error| malformed(path, error.to_string()))?;
629 let string_lines = json_string_lines(content);
630 let object = root
631 .as_object()
632 .ok_or_else(|| malformed(path, "top-level JSON value must be an object"))?;
633 let mut result = PackageManifest::default();
634
635 if let Some(name_value) = object.get("name") {
636 let name = required_json_string(path, "name", name_value)?;
637 let version = object
638 .get("version")
639 .map(|value| required_json_string(path, "version", value))
640 .transpose()?;
641 push_package(
642 &mut result.packages,
643 package(
644 PackageEcosystem::Npm,
645 name.to_owned(),
646 version.map(str::to_owned),
647 path,
648 evidence_at(content, json_string_line(&string_lines, "name", 1)),
649 ),
650 tracker,
651 )?;
652 } else if object.contains_key("version") {
653 return Err(malformed(path, "`version` requires a static `name`"));
654 }
655
656 let optional_peers = optional_npm_peers(path, object)?;
657 for (section, scope, section_optional) in [
658 ("dependencies", DependencyScope::Runtime, false),
659 ("devDependencies", DependencyScope::Dev, false),
660 ("peerDependencies", DependencyScope::Peer, false),
661 ("optionalDependencies", DependencyScope::Optional, true),
662 ] {
663 let Some(value) = object.get(section) else {
664 continue;
665 };
666 let entries = value
667 .as_object()
668 .ok_or_else(|| malformed(path, format!("`{section}` must be an object")))?;
669 let section_line = json_string_line(&string_lines, section, 1);
670 for (name, version) in entries {
671 let version = required_json_string(path, section, version)?;
672 let optional = section_optional
673 || (section == "peerDependencies" && optional_peers.contains(name));
674 push_dependency(
675 &mut result.dependencies,
676 dependency(DependencyInput {
677 ecosystem: PackageEcosystem::Npm,
678 name: name.clone(),
679 version: Some(version.to_owned()),
680 scope,
681 optional,
682 condition: None,
683 path,
684 evidence: evidence_at(
685 content,
686 json_string_line(&string_lines, name, section_line),
687 ),
688 confidence: EXACT_CONFIDENCE,
689 }),
690 tracker,
691 )?;
692 }
693 }
694
695 if let Some(workspaces) = object.get("workspaces") {
696 let values = if let Some(array) = workspaces.as_array() {
697 array
698 } else {
699 workspaces
700 .as_object()
701 .and_then(|map| map.get("packages"))
702 .and_then(Value::as_array)
703 .ok_or_else(|| {
704 malformed(
705 path,
706 "`workspaces` must be an array or an object with a `packages` array",
707 )
708 })?
709 };
710 for value in values {
711 let member = required_json_string(path, "workspaces", value)?;
712 push_value(
713 &mut result.workspace_members,
714 value_fact_at(
715 path,
716 member.to_owned(),
717 json_string_line(&string_lines, member, 1),
718 ),
719 tracker,
720 )?;
721 }
722 }
723
724 if let Some(exports) = object.get("exports") {
725 collect_json_export_keys(path, exports, &string_lines, &mut result.exports, tracker)?;
726 }
727 Ok(result)
728}
729
730fn optional_npm_peers(
731 path: &str,
732 object: &serde_json::Map<String, Value>,
733) -> Result<BTreeSet<String>, PackageManifestError> {
734 let Some(value) = object.get("peerDependenciesMeta") else {
735 return Ok(BTreeSet::new());
736 };
737 let entries = value
738 .as_object()
739 .ok_or_else(|| malformed(path, "`peerDependenciesMeta` must be an object"))?;
740 let mut optional = BTreeSet::new();
741 for (name, metadata) in entries {
742 let metadata = metadata.as_object().ok_or_else(|| {
743 malformed(
744 path,
745 format!("peer metadata for `{name}` must be an object"),
746 )
747 })?;
748 if let Some(flag) = metadata.get("optional") {
749 let flag = flag.as_bool().ok_or_else(|| {
750 malformed(
751 path,
752 format!("peer metadata `optional` for `{name}` must be a boolean"),
753 )
754 })?;
755 if flag {
756 optional.insert(name.clone());
757 }
758 }
759 }
760 Ok(optional)
761}
762
763fn required_json_string<'a>(
764 path: &str,
765 field: &str,
766 value: &'a Value,
767) -> Result<&'a str, PackageManifestError> {
768 value
769 .as_str()
770 .filter(|text| !text.trim().is_empty())
771 .ok_or_else(|| malformed(path, format!("`{field}` must contain non-empty strings")))
772}
773
774fn collect_json_export_keys(
775 path: &str,
776 exports: &Value,
777 string_lines: &BTreeMap<String, Vec<usize>>,
778 output: &mut Vec<PackageManifestValue>,
779 tracker: &mut ExtractionTracker,
780) -> Result<(), PackageManifestError> {
781 match exports {
782 Value::String(target) if !target.is_empty() => {
783 push_value(
784 output,
785 value_fact_at(
786 path,
787 ".".to_owned(),
788 json_string_line(string_lines, "exports", 1),
789 ),
790 tracker,
791 )?;
792 }
793 Value::Object(map) => {
794 for (key, value) in map {
795 if key.starts_with('.') {
796 push_value(
797 output,
798 value_fact_at(path, key.clone(), json_string_line(string_lines, key, 1)),
799 tracker,
800 )?;
801 }
802 validate_json_export_target(path, value)?;
803 }
804 }
805 _ => return Err(malformed(path, "`exports` must be a string or object")),
806 }
807 Ok(())
808}
809
810fn validate_json_export_target(path: &str, value: &Value) -> Result<(), PackageManifestError> {
811 match value {
812 Value::String(_) | Value::Null => Ok(()),
813 Value::Array(values) => {
814 for item in values {
815 validate_json_export_target(path, item)?;
816 }
817 Ok(())
818 }
819 Value::Object(map) => {
820 for item in map.values() {
821 validate_json_export_target(path, item)?;
822 }
823 Ok(())
824 }
825 _ => Err(malformed(
826 path,
827 "`exports` targets must be strings, null, arrays, or condition objects",
828 )),
829 }
830}
831
832fn parse_npm_lockfile(
833 path: &str,
834 content: &str,
835 tracker: &mut ExtractionTracker,
836) -> Result<PackageManifest, PackageManifestError> {
837 let root: Value =
838 serde_json::from_str(content).map_err(|error| malformed(path, error.to_string()))?;
839 let object = root
840 .as_object()
841 .ok_or_else(|| malformed(path, "top-level JSON value must be an object"))?;
842 let version = object
843 .get("lockfileVersion")
844 .map(|value| match value {
845 Value::Number(number) => Ok(number.to_string()),
846 Value::String(text) if !text.is_empty() => Ok(text.clone()),
847 _ => Err(malformed(
848 path,
849 "`lockfileVersion` must be a string or number",
850 )),
851 })
852 .transpose()?;
853 if object.is_empty() {
854 return Err(malformed(path, "lockfile object must not be empty"));
855 }
856 let token = if version.is_some() {
857 "\"lockfileVersion\""
858 } else {
859 "{"
860 };
861 lockfile_result(path, content, "npm", version, token, tracker)
862}
863
864fn parse_pnpm_lockfile(
865 path: &str,
866 content: &str,
867 tracker: &mut ExtractionTracker,
868) -> Result<PackageManifest, PackageManifestError> {
869 reject_nul(path, content)?;
870 let declaration = content
871 .lines()
872 .enumerate()
873 .find_map(|(index, line)| {
874 let trimmed = line.trim();
875 trimmed
876 .strip_prefix("lockfileVersion:")
877 .map(|value| (index + 1, unquote_scalar(value.trim())))
878 })
879 .ok_or_else(|| malformed(path, "missing static `lockfileVersion`"))?;
880 if declaration.1.is_empty() {
881 return Err(malformed(path, "`lockfileVersion` must not be empty"));
882 }
883 lockfile_result_at(
884 path,
885 content,
886 "pnpm",
887 Some(declaration.1),
888 declaration.0,
889 tracker,
890 )
891}
892
893fn parse_yarn_lockfile(
894 path: &str,
895 content: &str,
896 tracker: &mut ExtractionTracker,
897) -> Result<PackageManifest, PackageManifestError> {
898 reject_nul(path, content)?;
899 let first = content
900 .lines()
901 .enumerate()
902 .find(|(_, line)| !line.trim().is_empty())
903 .ok_or_else(|| malformed(path, "lockfile must not be empty"))?;
904 let is_v1 = first.1.contains("yarn lockfile v1");
905 let metadata_line = content
906 .lines()
907 .enumerate()
908 .find(|(_, line)| line.trim() == "__metadata:")
909 .map(|(index, _)| index + 1);
910 if !is_v1 && metadata_line.is_none() {
911 return Err(malformed(
912 path,
913 "missing Yarn v1 header or Berry `__metadata` section",
914 ));
915 }
916 let version = if is_v1 {
917 Some("1".to_owned())
918 } else {
919 metadata_line.and_then(|start| {
920 content
921 .lines()
922 .skip(start)
923 .find_map(|line| line.trim().strip_prefix("version:").map(str::trim))
924 .map(unquote_scalar)
925 })
926 };
927 lockfile_result_at(
928 path,
929 content,
930 "yarn",
931 version,
932 metadata_line.unwrap_or(first.0 + 1),
933 tracker,
934 )
935}
936
937fn lockfile_result(
938 path: &str,
939 content: &str,
940 manager: &str,
941 version: Option<String>,
942 token: &str,
943 tracker: &mut ExtractionTracker,
944) -> Result<PackageManifest, PackageManifestError> {
945 let evidence = evidence_for(content, 1, token);
946 lockfile_result_with_evidence(path, manager, version, evidence, tracker)
947}
948
949fn lockfile_result_at(
950 path: &str,
951 content: &str,
952 manager: &str,
953 version: Option<String>,
954 line: usize,
955 tracker: &mut ExtractionTracker,
956) -> Result<PackageManifest, PackageManifestError> {
957 lockfile_result_with_evidence(path, manager, version, evidence_at(content, line), tracker)
958}
959
960fn lockfile_result_with_evidence(
961 path: &str,
962 manager: &str,
963 version: Option<String>,
964 evidence: PackageEvidenceLine,
965 tracker: &mut ExtractionTracker,
966) -> Result<PackageManifest, PackageManifestError> {
967 ecosystem_lockfile_result(
968 path,
969 PackageEcosystem::Npm,
970 manager,
971 version,
972 evidence,
973 tracker,
974 )
975}
976
977fn ecosystem_lockfile_result(
978 path: &str,
979 ecosystem: PackageEcosystem,
980 manager: &str,
981 version: Option<String>,
982 evidence: PackageEvidenceLine,
983 tracker: &mut ExtractionTracker,
984) -> Result<PackageManifest, PackageManifestError> {
985 let mut result = PackageManifest::default();
986 push_lockfile(
987 &mut result.lockfiles,
988 LockfileMetadata {
989 ecosystem,
990 package_manager: manager.to_owned(),
991 format_version: version,
992 source_path: path.to_owned(),
993 evidence,
994 confidence: PRESENCE_CONFIDENCE,
995 },
996 tracker,
997 )?;
998 Ok(result)
999}
1000
1001fn parse_cargo_lockfile(
1002 path: &str,
1003 content: &str,
1004 tracker: &mut ExtractionTracker,
1005) -> Result<PackageManifest, PackageManifestError> {
1006 reject_nul(path, content)?;
1007 let first_content_line = content
1008 .lines()
1009 .enumerate()
1010 .find(|(_, line)| {
1011 let line = line.trim();
1012 !line.is_empty() && !line.starts_with('#')
1013 })
1014 .ok_or_else(|| malformed(path, "lockfile must not be empty"))?;
1015 let version = content
1016 .lines()
1017 .enumerate()
1018 .take_while(|(_, line)| line.trim() != "[[package]]")
1019 .find_map(|(index, line)| {
1020 line.trim()
1021 .strip_prefix("version =")
1022 .map(|value| (index + 1, unquote_scalar(value.trim())))
1023 });
1024 if version
1025 .as_ref()
1026 .is_some_and(|(_, version)| version.is_empty())
1027 {
1028 return Err(malformed(path, "`version` must not be empty"));
1029 }
1030 let evidence = version.as_ref().map_or_else(
1031 || evidence_at(content, first_content_line.0 + 1),
1032 |(line, _)| evidence_at(content, *line),
1033 );
1034 ecosystem_lockfile_result(
1035 path,
1036 PackageEcosystem::Cargo,
1037 "cargo",
1038 version.map(|(_, value)| value),
1039 evidence,
1040 tracker,
1041 )
1042}
1043
1044#[derive(Debug)]
1045struct TomlEntry {
1046 section: String,
1047 key: String,
1048 value: String,
1049 line: usize,
1050}
1051
1052fn parse_toml(
1053 path: &str,
1054 content: &str,
1055 tracker: &ExtractionTracker,
1056) -> Result<Vec<TomlEntry>, PackageManifestError> {
1057 reject_nul(path, content)?;
1058 let lines = content.lines().collect::<Vec<_>>();
1059 let mut entries = Vec::new();
1060 let mut section = String::new();
1061 let mut index = 0;
1062 while index < lines.len() {
1063 if index.is_multiple_of(1_024) {
1064 tracker.check_structured_time()?;
1065 }
1066 let first_line = index + 1;
1067 let stripped = strip_line_comment(lines[index], '#')?;
1068 let trimmed = stripped.trim();
1069 if trimmed.is_empty() {
1070 index += 1;
1071 continue;
1072 }
1073 if trimmed.starts_with('[') {
1074 let (open, close) = if trimmed.starts_with("[[") {
1075 ("[[", "]]")
1076 } else {
1077 ("[", "]")
1078 };
1079 if !trimmed.ends_with(close) {
1080 return Err(malformed(
1081 path,
1082 format!("unterminated table at line {first_line}"),
1083 ));
1084 }
1085 let name = &trimmed[open.len()..trimmed.len() - close.len()];
1086 if name.trim().is_empty() {
1087 return Err(malformed(path, format!("empty table at line {first_line}")));
1088 }
1089 name.trim().clone_into(&mut section);
1090 index += 1;
1091 continue;
1092 }
1093 let Some((key, initial_value)) = split_top_level_once(trimmed, '=') else {
1094 return Err(malformed(
1095 path,
1096 format!("expected a key/value declaration at line {first_line}"),
1097 ));
1098 };
1099 let key = unquote_scalar(key.trim());
1100 if key.is_empty() {
1101 return Err(malformed(path, format!("empty key at line {first_line}")));
1102 }
1103 let mut value = initial_value.trim().to_owned();
1104 while !structured_value_complete(&value)? {
1105 index += 1;
1106 let Some(next) = lines.get(index) else {
1107 return Err(malformed(
1108 path,
1109 format!("unterminated value beginning at line {first_line}"),
1110 ));
1111 };
1112 let next = strip_line_comment(next, '#')?;
1113 value.push('\n');
1114 value.push_str(next.trim());
1115 }
1116 if value.is_empty() {
1117 return Err(malformed(path, format!("empty value at line {first_line}")));
1118 }
1119 entries.push(TomlEntry {
1120 section: section.clone(),
1121 key,
1122 value,
1123 line: first_line,
1124 });
1125 index += 1;
1126 }
1127 Ok(entries)
1128}
1129
1130fn strip_line_comment(line: &str, marker: char) -> Result<&str, PackageManifestError> {
1131 let mut quote = None;
1132 let mut escaped = false;
1133 for (index, character) in line.char_indices() {
1134 if escaped {
1135 escaped = false;
1136 continue;
1137 }
1138 if character == '\\' && quote == Some('"') {
1139 escaped = true;
1140 continue;
1141 }
1142 if character == '\'' || character == '"' {
1143 if quote == Some(character) {
1144 quote = None;
1145 } else if quote.is_none() {
1146 quote = Some(character);
1147 }
1148 } else if character == marker && quote.is_none() {
1149 return Ok(&line[..index]);
1150 }
1151 }
1152 if quote.is_some() {
1153 return Err(PackageManifestError::Malformed {
1154 path: String::new(),
1155 message: "unterminated quoted string".to_owned(),
1156 });
1157 }
1158 Ok(line)
1159}
1160
1161fn structured_value_complete(value: &str) -> Result<bool, PackageManifestError> {
1162 let mut square = 0_i32;
1163 let mut curly = 0_i32;
1164 let mut quote = None;
1165 let mut escaped = false;
1166 for character in value.chars() {
1167 if escaped {
1168 escaped = false;
1169 continue;
1170 }
1171 if character == '\\' && quote == Some('"') {
1172 escaped = true;
1173 continue;
1174 }
1175 if character == '\'' || character == '"' {
1176 if quote == Some(character) {
1177 quote = None;
1178 } else if quote.is_none() {
1179 quote = Some(character);
1180 }
1181 continue;
1182 }
1183 if quote.is_some() {
1184 continue;
1185 }
1186 match character {
1187 '[' => square += 1,
1188 ']' => square -= 1,
1189 '{' => curly += 1,
1190 '}' => curly -= 1,
1191 _ => {}
1192 }
1193 if square < 0 || curly < 0 {
1194 return Err(PackageManifestError::Malformed {
1195 path: String::new(),
1196 message: "unbalanced structured value".to_owned(),
1197 });
1198 }
1199 }
1200 Ok(square == 0 && curly == 0 && quote.is_none())
1201}
1202
1203fn split_top_level_once(input: &str, separator: char) -> Option<(&str, &str)> {
1204 let mut square = 0_i32;
1205 let mut curly = 0_i32;
1206 let mut round = 0_i32;
1207 let mut quote = None;
1208 let mut escaped = false;
1209 for (index, character) in input.char_indices() {
1210 if escaped {
1211 escaped = false;
1212 continue;
1213 }
1214 if character == '\\' && quote == Some('"') {
1215 escaped = true;
1216 continue;
1217 }
1218 if character == '\'' || character == '"' {
1219 if quote == Some(character) {
1220 quote = None;
1221 } else if quote.is_none() {
1222 quote = Some(character);
1223 }
1224 continue;
1225 }
1226 if quote.is_some() {
1227 continue;
1228 }
1229 match character {
1230 '[' => square += 1,
1231 ']' => square -= 1,
1232 '{' => curly += 1,
1233 '}' => curly -= 1,
1234 '(' => round += 1,
1235 ')' => round -= 1,
1236 _ => {}
1237 }
1238 if character == separator && square == 0 && curly == 0 && round == 0 {
1239 return Some((&input[..index], &input[index + character.len_utf8()..]));
1240 }
1241 }
1242 None
1243}
1244
1245fn split_top_level(input: &str, separator: char) -> Vec<&str> {
1246 let mut result = Vec::new();
1247 let mut rest = input;
1248 while let Some((left, right)) = split_top_level_once(rest, separator) {
1249 result.push(left);
1250 rest = right;
1251 }
1252 result.push(rest);
1253 result
1254}
1255
1256fn unquote_scalar(value: &str) -> String {
1257 let trimmed = value.trim();
1258 if trimmed.len() >= 2
1259 && ((trimmed.starts_with('"') && trimmed.ends_with('"'))
1260 || (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
1261 {
1262 trimmed[1..trimmed.len() - 1].to_owned()
1263 } else {
1264 trimmed.to_owned()
1265 }
1266}
1267
1268fn toml_string(path: &str, entry: &TomlEntry) -> Result<String, PackageManifestError> {
1269 let trimmed = entry.value.trim();
1270 if trimmed.starts_with('"') && trimmed.ends_with('"') {
1271 return serde_json::from_str::<String>(trimmed)
1272 .map_err(|error| malformed(path, format!("line {}: {error}", entry.line)));
1273 }
1274 if trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2 {
1275 return Ok(trimmed[1..trimmed.len() - 1].to_owned());
1276 }
1277 Err(malformed(
1278 path,
1279 format!("`{}` at line {} must be a string", entry.key, entry.line),
1280 ))
1281}
1282
1283fn toml_array(path: &str, entry: &TomlEntry) -> Result<Vec<String>, PackageManifestError> {
1284 let trimmed = entry.value.trim();
1285 if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
1286 return Err(malformed(
1287 path,
1288 format!("`{}` at line {} must be an array", entry.key, entry.line),
1289 ));
1290 }
1291 let inner = &trimmed[1..trimmed.len() - 1];
1292 let mut values = Vec::new();
1293 for item in split_top_level(inner, ',') {
1294 let item = item.trim();
1295 if item.is_empty() {
1296 continue;
1297 }
1298 let fake = TomlEntry {
1299 section: entry.section.clone(),
1300 key: entry.key.clone(),
1301 value: item.to_owned(),
1302 line: entry.line,
1303 };
1304 values.push(toml_string(path, &fake)?);
1305 }
1306 Ok(values)
1307}
1308
1309fn inline_table(
1310 path: &str,
1311 entry: &TomlEntry,
1312) -> Result<BTreeMap<String, String>, PackageManifestError> {
1313 let trimmed = entry.value.trim();
1314 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1315 return Err(malformed(
1316 path,
1317 format!(
1318 "`{}` at line {} must be an inline table",
1319 entry.key, entry.line
1320 ),
1321 ));
1322 }
1323 let mut values = BTreeMap::new();
1324 for item in split_top_level(&trimmed[1..trimmed.len() - 1], ',') {
1325 let item = item.trim();
1326 if item.is_empty() {
1327 continue;
1328 }
1329 let (key, value) = split_top_level_once(item, '=').ok_or_else(|| {
1330 malformed(path, format!("invalid inline table at line {}", entry.line))
1331 })?;
1332 values.insert(unquote_scalar(key), value.trim().to_owned());
1333 }
1334 Ok(values)
1335}
1336
1337fn entry<'a>(entries: &'a [TomlEntry], section: &str, key: &str) -> Option<&'a TomlEntry> {
1338 entries
1339 .iter()
1340 .find(|item| item.section == section && item.key == key)
1341}
1342
1343fn parse_pyproject(
1344 path: &str,
1345 content: &str,
1346 tracker: &mut ExtractionTracker,
1347) -> Result<PackageManifest, PackageManifestError> {
1348 let entries = parse_toml(path, content, tracker).map_err(|error| rehome_error(path, error))?;
1349 let mut result = PackageManifest::default();
1350 extract_python_package(path, content, &entries, &mut result, tracker)?;
1351 extract_pep621_dependencies(path, content, &entries, &mut result, tracker)?;
1352 extract_poetry_dependencies(path, content, &entries, &mut result, tracker)?;
1353 Ok(result)
1354}
1355
1356fn extract_python_package(
1357 path: &str,
1358 content: &str,
1359 entries: &[TomlEntry],
1360 result: &mut PackageManifest,
1361 tracker: &mut ExtractionTracker,
1362) -> Result<(), PackageManifestError> {
1363 let project_name = entry(entries, "project", "name")
1364 .map(|item| toml_string(path, item))
1365 .transpose()?;
1366 let poetry_name = entry(entries, "tool.poetry", "name")
1367 .map(|item| toml_string(path, item))
1368 .transpose()?;
1369 if let Some(name) = project_name.as_ref().or(poetry_name.as_ref()) {
1370 let section = if project_name.is_some() {
1371 "project"
1372 } else {
1373 "tool.poetry"
1374 };
1375 let name_entry = entry(entries, section, "name")
1376 .ok_or_else(|| malformed(path, "package name declaration disappeared"))?;
1377 let version = entry(entries, section, "version")
1378 .map(|item| toml_string(path, item))
1379 .transpose()?;
1380 push_package(
1381 &mut result.packages,
1382 package(
1383 PackageEcosystem::Python,
1384 name.clone(),
1385 version,
1386 path,
1387 evidence_at(content, name_entry.line),
1388 ),
1389 tracker,
1390 )?;
1391 }
1392 Ok(())
1393}
1394
1395fn extract_pep621_dependencies(
1396 path: &str,
1397 content: &str,
1398 entries: &[TomlEntry],
1399 result: &mut PackageManifest,
1400 tracker: &mut ExtractionTracker,
1401) -> Result<(), PackageManifestError> {
1402 if let Some(dependencies) = entry(entries, "project", "dependencies") {
1403 for requirement in toml_array(path, dependencies)? {
1404 if let Some(parsed) = parse_python_requirement(&requirement) {
1405 push_dependency(
1406 &mut result.dependencies,
1407 python_dependency(
1408 path,
1409 content,
1410 parsed,
1411 DependencyScope::Runtime,
1412 false,
1413 dependencies.line,
1414 &requirement,
1415 ),
1416 tracker,
1417 )?;
1418 }
1419 }
1420 }
1421 for item in entries {
1422 if let Some(extra) = item.section.strip_prefix("project.optional-dependencies") {
1423 let extra = extra.trim_start_matches('.');
1424 if extra.is_empty() {
1425 for requirement in toml_array(path, item)? {
1426 if let Some(parsed) = parse_python_requirement(&requirement) {
1427 push_dependency(
1428 &mut result.dependencies,
1429 python_dependency(
1430 path,
1431 content,
1432 parsed.with_condition(format!("extra = \"{}\"", item.key)),
1433 DependencyScope::Optional,
1434 true,
1435 item.line,
1436 &requirement,
1437 ),
1438 tracker,
1439 )?;
1440 }
1441 }
1442 }
1443 }
1444 }
1445 Ok(())
1446}
1447
1448fn extract_poetry_dependencies(
1449 path: &str,
1450 content: &str,
1451 entries: &[TomlEntry],
1452 result: &mut PackageManifest,
1453 tracker: &mut ExtractionTracker,
1454) -> Result<(), PackageManifestError> {
1455 for item in entries {
1456 let (scope, group_condition) = if item.section == "tool.poetry.dependencies" {
1457 (DependencyScope::Runtime, None)
1458 } else if item.section == "tool.poetry.dev-dependencies" {
1459 (DependencyScope::Dev, None)
1460 } else if let Some(group) = item
1461 .section
1462 .strip_prefix("tool.poetry.group.")
1463 .and_then(|tail| tail.strip_suffix(".dependencies"))
1464 {
1465 (
1466 if group.eq_ignore_ascii_case("test") {
1467 DependencyScope::Test
1468 } else {
1469 DependencyScope::Dev
1470 },
1471 Some(format!("poetry group = \"{group}\"")),
1472 )
1473 } else {
1474 continue;
1475 };
1476 if item.key == "python" || contains_dynamic(&item.key) {
1477 continue;
1478 }
1479 let (version, optional, table_condition) = parse_poetry_value(path, item)?;
1480 let condition = join_conditions(group_condition, table_condition);
1481 push_dependency(
1482 &mut result.dependencies,
1483 dependency(DependencyInput {
1484 ecosystem: PackageEcosystem::Python,
1485 name: item.key.clone(),
1486 version,
1487 scope: if optional {
1488 DependencyScope::Optional
1489 } else {
1490 scope
1491 },
1492 optional,
1493 condition,
1494 path,
1495 evidence: evidence_at(content, item.line),
1496 confidence: EXACT_CONFIDENCE,
1497 }),
1498 tracker,
1499 )?;
1500 }
1501 Ok(())
1502}
1503
1504fn rehome_error(path: &str, error: PackageManifestError) -> PackageManifestError {
1505 match error {
1506 PackageManifestError::Malformed { message, .. } => malformed(path, message),
1507 other => other,
1508 }
1509}
1510
1511fn parse_poetry_value(
1512 path: &str,
1513 item: &TomlEntry,
1514) -> Result<(Option<String>, bool, Option<String>), PackageManifestError> {
1515 if item.value.trim().starts_with(['"', '\'']) {
1516 return Ok((Some(toml_string(path, item)?), false, None));
1517 }
1518 let values = inline_table(path, item)?;
1519 let version = values.get("version").map(|value| unquote_scalar(value));
1520 let optional = values
1521 .get("optional")
1522 .is_some_and(|value| value.trim() == "true");
1523 let mut conditions = Vec::new();
1524 for key in ["markers", "python", "platform"] {
1525 if let Some(value) = values.get(key) {
1526 conditions.push(format!("{key} = {}", value.trim()));
1527 }
1528 }
1529 Ok((
1530 version,
1531 optional,
1532 (!conditions.is_empty()).then(|| conditions.join(" and ")),
1533 ))
1534}
1535
1536#[derive(Debug)]
1537struct PythonRequirement {
1538 name: String,
1539 version: Option<String>,
1540 condition: Option<String>,
1541}
1542
1543impl PythonRequirement {
1544 fn with_condition(mut self, condition: String) -> Self {
1545 self.condition = join_conditions(Some(condition), self.condition);
1546 self
1547 }
1548}
1549
1550fn parse_python_requirement(input: &str) -> Option<PythonRequirement> {
1551 let trimmed = input.trim();
1552 if trimmed.is_empty()
1553 || trimmed.starts_with('-')
1554 || trimmed.contains("${")
1555 || trimmed.contains(" @ ")
1556 || trimmed.starts_with("git+")
1557 {
1558 return None;
1559 }
1560 let (declaration, marker) = trimmed
1561 .split_once(';')
1562 .map_or((trimmed, None), |(left, right)| {
1563 (left.trim(), Some(right.trim().to_owned()))
1564 });
1565 let name_end = declaration
1566 .char_indices()
1567 .find_map(|(index, character)| {
1568 (character.is_whitespace() || matches!(character, '<' | '>' | '=' | '!' | '~' | '@'))
1569 .then_some(index)
1570 })
1571 .unwrap_or(declaration.len());
1572 let name = declaration[..name_end].trim();
1573 if name.is_empty()
1574 || !name
1575 .chars()
1576 .all(|character| character.is_ascii_alphanumeric() || "._-[]".contains(character))
1577 {
1578 return None;
1579 }
1580 let version = declaration[name_end..].trim();
1581 Some(PythonRequirement {
1582 name: name.to_owned(),
1583 version: (!version.is_empty()).then(|| version.to_owned()),
1584 condition: marker.filter(|value| !value.is_empty()),
1585 })
1586}
1587
1588fn python_dependency(
1589 path: &str,
1590 content: &str,
1591 parsed: PythonRequirement,
1592 scope: DependencyScope,
1593 optional: bool,
1594 start_line: usize,
1595 token: &str,
1596) -> PackageDependency {
1597 dependency(DependencyInput {
1598 ecosystem: PackageEcosystem::Python,
1599 name: parsed.name,
1600 version: parsed.version,
1601 scope,
1602 optional,
1603 condition: parsed.condition,
1604 path,
1605 evidence: evidence_for(content, start_line, token),
1606 confidence: EXACT_CONFIDENCE,
1607 })
1608}
1609
1610fn parse_requirements(
1611 path: &str,
1612 content: &str,
1613 tracker: &mut ExtractionTracker,
1614) -> Result<PackageManifest, PackageManifestError> {
1615 reject_nul(path, content)?;
1616 let mut result = PackageManifest::default();
1617 for (index, line) in content.lines().enumerate() {
1618 if index.is_multiple_of(1_024) {
1619 tracker.check_structured_time()?;
1620 }
1621 let declaration = strip_requirement_comment(line).trim();
1622 if declaration.is_empty() || declaration.starts_with('-') {
1623 continue;
1624 }
1625 if declaration.ends_with('\\') {
1626 return Err(malformed(
1627 path,
1628 format!("line continuations are not supported at line {}", index + 1),
1629 ));
1630 }
1631 if let Some(parsed) = parse_python_requirement(declaration) {
1632 push_dependency(
1633 &mut result.dependencies,
1634 python_dependency(
1635 path,
1636 content,
1637 parsed,
1638 DependencyScope::Runtime,
1639 false,
1640 index + 1,
1641 declaration,
1642 ),
1643 tracker,
1644 )?;
1645 }
1646 }
1647 Ok(result)
1648}
1649
1650fn strip_requirement_comment(line: &str) -> &str {
1651 line.find(" #")
1652 .map_or(line, |index| &line[..index])
1653 .trim_end()
1654}
1655
1656fn parse_cargo_manifest(
1657 path: &str,
1658 content: &str,
1659 tracker: &mut ExtractionTracker,
1660) -> Result<PackageManifest, PackageManifestError> {
1661 let entries = parse_toml(path, content, tracker).map_err(|error| rehome_error(path, error))?;
1662 let mut result = PackageManifest::default();
1663 if let Some(name_entry) = entry(&entries, "package", "name") {
1664 let name = toml_string(path, name_entry)?;
1665 let version = entry(&entries, "package", "version")
1666 .map(|item| toml_string(path, item))
1667 .transpose()?;
1668 push_package(
1669 &mut result.packages,
1670 package(
1671 PackageEcosystem::Cargo,
1672 name,
1673 version,
1674 path,
1675 evidence_at(content, name_entry.line),
1676 ),
1677 tracker,
1678 )?;
1679 }
1680 if let Some(members) = entry(&entries, "workspace", "members") {
1681 for member in toml_array(path, members)? {
1682 push_value(
1683 &mut result.workspace_members,
1684 value_fact(path, content, member.clone(), members.line, &member),
1685 tracker,
1686 )?;
1687 }
1688 }
1689
1690 let mut feature_dependencies: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1691 for feature in entries.iter().filter(|item| item.section == "features") {
1692 push_value(
1693 &mut result.features,
1694 value_fact(
1695 path,
1696 content,
1697 feature.key.clone(),
1698 feature.line,
1699 &feature.key,
1700 ),
1701 tracker,
1702 )?;
1703 for member in toml_array(path, feature)? {
1704 let dependency_name = member
1705 .strip_prefix("dep:")
1706 .or_else(|| (!member.contains('/')).then_some(member.as_str()));
1707 if let Some(name) = dependency_name {
1708 feature_dependencies
1709 .entry(name.to_owned())
1710 .or_default()
1711 .insert(feature.key.clone());
1712 }
1713 }
1714 }
1715
1716 for item in &entries {
1717 let Some((scope, target)) = cargo_dependency_section(&item.section) else {
1718 continue;
1719 };
1720 let (name, version, optional, declared_features) = parse_cargo_dependency(path, item)?;
1721 let features = feature_dependencies.get(&item.key);
1722 let feature_condition = features.map(|names| {
1723 let joined = names.iter().cloned().collect::<Vec<_>>().join("|");
1724 format!("feature = \"{joined}\"")
1725 });
1726 let implicit_feature =
1727 (optional && features.is_none()).then(|| format!("feature = \"{}\"", item.key));
1728 let dependency_features = (!declared_features.is_empty())
1729 .then(|| format!("dependency features = \"{}\"", declared_features.join("|")));
1730 push_dependency(
1731 &mut result.dependencies,
1732 dependency(DependencyInput {
1733 ecosystem: PackageEcosystem::Cargo,
1734 name,
1735 version,
1736 scope,
1737 optional,
1738 condition: join_conditions(
1739 join_conditions(target, feature_condition.or(implicit_feature)),
1740 dependency_features,
1741 ),
1742 path,
1743 evidence: evidence_at(content, item.line),
1744 confidence: EXACT_CONFIDENCE,
1745 }),
1746 tracker,
1747 )?;
1748 }
1749 Ok(result)
1750}
1751
1752fn cargo_dependency_section(section: &str) -> Option<(DependencyScope, Option<String>)> {
1753 let (prefix, scope) = if let Some(prefix) = section.strip_suffix(".dev-dependencies") {
1754 (prefix, DependencyScope::Dev)
1755 } else if let Some(prefix) = section.strip_suffix(".build-dependencies") {
1756 (prefix, DependencyScope::Build)
1757 } else if let Some(prefix) = section.strip_suffix(".dependencies") {
1758 (prefix, DependencyScope::Runtime)
1759 } else {
1760 return match section {
1761 "dependencies" => Some((DependencyScope::Runtime, None)),
1762 "dev-dependencies" => Some((DependencyScope::Dev, None)),
1763 "build-dependencies" => Some((DependencyScope::Build, None)),
1764 _ => None,
1765 };
1766 };
1767 let target = prefix
1768 .strip_prefix("target.")
1769 .map(|value| format!("target = {}", value.trim_matches(['\'', '"'])));
1770 target.map(|condition| (scope, Some(condition)))
1771}
1772
1773fn parse_cargo_dependency(
1774 path: &str,
1775 item: &TomlEntry,
1776) -> Result<(String, Option<String>, bool, Vec<String>), PackageManifestError> {
1777 if item.value.trim().starts_with(['"', '\'']) {
1778 return Ok((
1779 item.key.clone(),
1780 Some(toml_string(path, item)?),
1781 false,
1782 Vec::new(),
1783 ));
1784 }
1785 let values = inline_table(path, item)?;
1786 let name = values
1787 .get("package")
1788 .map_or_else(|| item.key.clone(), |value| unquote_scalar(value));
1789 let version = values.get("version").map(|value| unquote_scalar(value));
1790 let optional = values
1791 .get("optional")
1792 .is_some_and(|value| value.trim() == "true");
1793 let mut features = values
1794 .get("features")
1795 .map(|value| {
1796 toml_array(
1797 path,
1798 &TomlEntry {
1799 section: item.section.clone(),
1800 key: "features".to_owned(),
1801 value: value.clone(),
1802 line: item.line,
1803 },
1804 )
1805 })
1806 .transpose()?
1807 .unwrap_or_default();
1808 features.sort();
1809 features.dedup();
1810 Ok((name, version, optional, features))
1811}
1812
1813fn parse_go_mod(
1814 path: &str,
1815 content: &str,
1816 tracker: &mut ExtractionTracker,
1817) -> Result<PackageManifest, PackageManifestError> {
1818 reject_nul(path, content)?;
1819 let mut result = PackageManifest::default();
1820 let mut require_block = false;
1821 let mut saw_module = false;
1822 for (index, line) in content.lines().enumerate() {
1823 if index.is_multiple_of(1_024) {
1824 tracker.check_structured_time()?;
1825 }
1826 let line_number = index + 1;
1827 let trimmed = line.trim();
1828 if let Some(module) = trimmed.strip_prefix("module ") {
1829 let module = module.trim();
1830 if module.is_empty() || saw_module {
1831 return Err(malformed(
1832 path,
1833 format!("invalid module at line {line_number}"),
1834 ));
1835 }
1836 saw_module = true;
1837 push_package(
1838 &mut result.packages,
1839 package(
1840 PackageEcosystem::Go,
1841 module.to_owned(),
1842 None,
1843 path,
1844 evidence_at(content, line_number),
1845 ),
1846 tracker,
1847 )?;
1848 continue;
1849 }
1850 if trimmed == "require (" {
1851 if require_block {
1852 return Err(malformed(
1853 path,
1854 format!("nested require block at line {line_number}"),
1855 ));
1856 }
1857 require_block = true;
1858 continue;
1859 }
1860 if trimmed == ")" && require_block {
1861 require_block = false;
1862 continue;
1863 }
1864 let declaration = if require_block {
1865 trimmed
1866 } else {
1867 trimmed.strip_prefix("require ").unwrap_or_default()
1868 };
1869 if declaration.is_empty() || declaration.starts_with("//") {
1870 continue;
1871 }
1872 let indirect = declaration.contains("// indirect");
1873 let declaration = declaration.split("//").next().unwrap_or_default().trim();
1874 let mut fields = declaration.split_whitespace();
1875 let Some(name) = fields.next() else {
1876 continue;
1877 };
1878 let Some(version) = fields.next() else {
1879 return Err(malformed(
1880 path,
1881 format!("requirement lacks a version at line {line_number}"),
1882 ));
1883 };
1884 if fields.next().is_some() {
1885 return Err(malformed(
1886 path,
1887 format!("invalid requirement at line {line_number}"),
1888 ));
1889 }
1890 push_dependency(
1891 &mut result.dependencies,
1892 dependency(DependencyInput {
1893 ecosystem: PackageEcosystem::Go,
1894 name: name.to_owned(),
1895 version: Some(version.to_owned()),
1896 scope: DependencyScope::Runtime,
1897 optional: indirect,
1898 condition: indirect.then(|| "indirect".to_owned()),
1899 path,
1900 evidence: evidence_at(content, line_number),
1901 confidence: EXACT_CONFIDENCE,
1902 }),
1903 tracker,
1904 )?;
1905 }
1906 if require_block {
1907 return Err(malformed(path, "unterminated require block"));
1908 }
1909 if !saw_module {
1910 return Err(malformed(path, "missing `module` declaration"));
1911 }
1912 Ok(result)
1913}
1914
1915fn parse_go_work(
1916 path: &str,
1917 content: &str,
1918 tracker: &mut ExtractionTracker,
1919) -> Result<PackageManifest, PackageManifestError> {
1920 reject_nul(path, content)?;
1921 let mut result = PackageManifest::default();
1922 let mut use_block = false;
1923 for (index, line) in content.lines().enumerate() {
1924 if index.is_multiple_of(1_024) {
1925 tracker.check_structured_time()?;
1926 }
1927 let line_number = index + 1;
1928 let trimmed = line.split("//").next().unwrap_or_default().trim();
1929 if trimmed == "use (" {
1930 if use_block {
1931 return Err(malformed(
1932 path,
1933 format!("nested use block at line {line_number}"),
1934 ));
1935 }
1936 use_block = true;
1937 continue;
1938 }
1939 if trimmed == ")" && use_block {
1940 use_block = false;
1941 continue;
1942 }
1943 let member = if use_block {
1944 trimmed
1945 } else {
1946 trimmed.strip_prefix("use ").unwrap_or_default()
1947 };
1948 if member.is_empty() || member.starts_with("go ") || member.starts_with("toolchain ") {
1949 continue;
1950 }
1951 if member.split_whitespace().count() != 1 {
1952 return Err(malformed(
1953 path,
1954 format!("invalid use declaration at line {line_number}"),
1955 ));
1956 }
1957 push_value(
1958 &mut result.workspace_members,
1959 value_fact(path, content, member.to_owned(), line_number, member),
1960 tracker,
1961 )?;
1962 }
1963 if use_block {
1964 return Err(malformed(path, "unterminated use block"));
1965 }
1966 Ok(result)
1967}
1968
1969#[derive(Debug, Default)]
1970struct XmlNode {
1971 name: String,
1972 attributes: BTreeMap<String, String>,
1973 text: String,
1974 line: usize,
1975 children: Vec<usize>,
1976}
1977
1978#[derive(Debug)]
1979struct XmlDocument {
1980 nodes: Vec<XmlNode>,
1981}
1982
1983impl XmlDocument {
1984 fn node(&self, index: usize) -> &XmlNode {
1985 &self.nodes[index]
1986 }
1987
1988 fn children<'a>(&'a self, node: &'a XmlNode) -> impl Iterator<Item = &'a XmlNode> + 'a {
1989 node.children.iter().map(|index| self.node(*index))
1990 }
1991}
1992
1993fn parse_xml(
1994 path: &str,
1995 content: &str,
1996 tracker: &mut ExtractionTracker,
1997) -> Result<XmlDocument, PackageManifestError> {
1998 reject_nul(path, content)?;
1999 let mut nodes = vec![XmlNode {
2000 name: "#document".to_owned(),
2001 line: 1,
2002 ..XmlNode::default()
2003 }];
2004 let mut stack = vec![0_usize];
2005 let mut cursor = 0;
2006 let mut line = 1_usize;
2007 while cursor < content.len() {
2008 tracker.charge_work(1)?;
2009 let Some(relative_open) = content[cursor..].find('<') else {
2010 append_xml_text(&mut nodes, &stack, &content[cursor..], tracker)?;
2011 break;
2012 };
2013 let open = cursor + relative_open;
2014 let open_line = line.saturating_add(count_newlines(&content[cursor..open]));
2015 append_xml_text(&mut nodes, &stack, &content[cursor..open], tracker)?;
2016 if content[open..].starts_with("<!--") {
2017 let end = content[open + 4..]
2018 .find("-->")
2019 .map(|index| open + 4 + index + 3)
2020 .ok_or_else(|| malformed(path, "unterminated XML comment"))?;
2021 advance_xml_cursor(content, &mut cursor, &mut line, end);
2022 continue;
2023 }
2024 if content[open..].starts_with("<![CDATA[") {
2025 let start = open + 9;
2026 let end = content[start..]
2027 .find("]]>")
2028 .map(|index| start + index)
2029 .ok_or_else(|| malformed(path, "unterminated CDATA section"))?;
2030 append_xml_text(&mut nodes, &stack, &content[start..end], tracker)?;
2031 advance_xml_cursor(content, &mut cursor, &mut line, end + 3);
2032 continue;
2033 }
2034 let close = find_xml_tag_end(content, open + 1)
2035 .ok_or_else(|| malformed(path, "unterminated XML tag"))?;
2036 let raw = content[open + 1..close].trim();
2037 advance_xml_cursor(content, &mut cursor, &mut line, close + 1);
2038 if raw.starts_with('?') || raw.starts_with('!') {
2039 continue;
2040 }
2041 if let Some(name) = raw.strip_prefix('/') {
2042 let name = local_xml_name(name.trim());
2043 if stack.len() <= 1 {
2044 return Err(malformed(path, format!("unexpected closing tag `{name}`")));
2045 }
2046 let node_index = stack
2047 .pop()
2048 .ok_or_else(|| malformed(path, "XML parser stack underflow"))?;
2049 let node = &nodes[node_index];
2050 if node.name != name {
2051 return Err(malformed(
2052 path,
2053 format!("closing tag `{name}` does not match `{}`", node.name),
2054 ));
2055 }
2056 continue;
2057 }
2058 let self_closing = raw.ends_with('/');
2059 let declaration = raw.trim_end_matches('/').trim();
2060 let (name, attributes) = parse_xml_opening(path, declaration)?;
2061 let depth = u64::try_from(stack.len()).unwrap_or(u64::MAX);
2062 tracker.check_structural_depth(depth)?;
2063 tracker.charge_identifier(&name)?;
2064 let node_index = nodes.len();
2065 nodes.push(XmlNode {
2066 name,
2067 attributes,
2068 text: String::new(),
2069 line: open_line,
2070 children: Vec::new(),
2071 });
2072 let parent = *stack
2073 .last()
2074 .ok_or_else(|| malformed(path, "XML parser stack underflow"))?;
2075 nodes[parent].children.push(node_index);
2076 if !self_closing {
2077 stack.push(node_index);
2078 }
2079 }
2080 if stack.len() != 1 {
2081 let name = stack.last().map_or("", |index| nodes[*index].name.as_str());
2082 return Err(malformed(path, format!("unclosed XML tag `{name}`")));
2083 }
2084 Ok(XmlDocument { nodes })
2085}
2086
2087fn count_newlines(value: &str) -> usize {
2088 value.bytes().filter(|byte| *byte == b'\n').count()
2089}
2090
2091fn advance_xml_cursor(content: &str, cursor: &mut usize, line: &mut usize, next: usize) {
2092 *line = line.saturating_add(count_newlines(&content[*cursor..next]));
2093 *cursor = next;
2094}
2095
2096fn append_xml_text(
2097 nodes: &mut [XmlNode],
2098 stack: &[usize],
2099 text: &str,
2100 tracker: &mut ExtractionTracker,
2101) -> Result<(), ExtractionLimitExceeded> {
2102 if !text.is_empty() {
2103 tracker.charge_string(text)?;
2104 }
2105 if let Some(index) = stack.last()
2106 && let Some(node) = nodes.get_mut(*index)
2107 {
2108 node.text.push_str(text);
2109 }
2110 Ok(())
2111}
2112
2113fn find_xml_tag_end(content: &str, start: usize) -> Option<usize> {
2114 let mut quote = None;
2115 for (relative, character) in content[start..].char_indices() {
2116 if character == '\'' || character == '"' {
2117 if quote == Some(character) {
2118 quote = None;
2119 } else if quote.is_none() {
2120 quote = Some(character);
2121 }
2122 } else if character == '>' && quote.is_none() {
2123 return Some(start + relative);
2124 }
2125 }
2126 None
2127}
2128
2129fn parse_xml_opening(
2130 path: &str,
2131 declaration: &str,
2132) -> Result<(String, BTreeMap<String, String>), PackageManifestError> {
2133 let name_end = declaration
2134 .find(char::is_whitespace)
2135 .unwrap_or(declaration.len());
2136 let name = local_xml_name(&declaration[..name_end]);
2137 if name.is_empty() {
2138 return Err(malformed(path, "empty XML element name"));
2139 }
2140 let mut attributes = BTreeMap::new();
2141 let mut rest = declaration[name_end..].trim();
2142 while !rest.is_empty() {
2143 let Some(equal) = rest.find('=') else {
2144 return Err(malformed(path, "XML attribute lacks `=`"));
2145 };
2146 let key = rest[..equal].trim();
2147 rest = rest[equal + 1..].trim_start();
2148 let quote = rest
2149 .chars()
2150 .next()
2151 .filter(|character| *character == '\'' || *character == '"')
2152 .ok_or_else(|| malformed(path, "XML attribute value must be quoted"))?;
2153 let tail = &rest[quote.len_utf8()..];
2154 let end = tail
2155 .find(quote)
2156 .ok_or_else(|| malformed(path, "unterminated XML attribute"))?;
2157 attributes.insert(local_xml_name(key), decode_xml_entities(&tail[..end]));
2158 rest = tail[end + quote.len_utf8()..].trim_start();
2159 }
2160 Ok((name, attributes))
2161}
2162
2163fn local_xml_name(name: &str) -> String {
2164 name.rsplit(':').next().unwrap_or(name).to_owned()
2165}
2166
2167fn decode_xml_entities(value: &str) -> String {
2168 value
2169 .replace(""", "\"")
2170 .replace("'", "'")
2171 .replace("<", "<")
2172 .replace(">", ">")
2173 .replace("&", "&")
2174}
2175
2176fn child<'a>(document: &'a XmlDocument, node: &'a XmlNode, name: &str) -> Option<&'a XmlNode> {
2177 document.children(node).find(|item| item.name == name)
2178}
2179
2180fn child_text(document: &XmlDocument, node: &XmlNode, name: &str) -> Option<String> {
2181 child(document, node, name)
2182 .map(|item| decode_xml_entities(item.text.trim()))
2183 .filter(|value| !value.is_empty())
2184}
2185
2186fn parse_maven(
2187 path: &str,
2188 content: &str,
2189 tracker: &mut ExtractionTracker,
2190) -> Result<PackageManifest, PackageManifestError> {
2191 let document = parse_xml(path, content, tracker)?;
2192 let project = document
2193 .children(document.node(0))
2194 .find(|node| node.name == "project")
2195 .ok_or_else(|| malformed(path, "missing `project` root element"))?;
2196 let mut result = PackageManifest::default();
2197 let group = child_text(&document, project, "groupId").or_else(|| {
2198 child(&document, project, "parent").and_then(|node| child_text(&document, node, "groupId"))
2199 });
2200 let artifact = child_text(&document, project, "artifactId");
2201 let version = child_text(&document, project, "version").or_else(|| {
2202 child(&document, project, "parent").and_then(|node| child_text(&document, node, "version"))
2203 });
2204 if let (Some(group), Some(artifact)) = (group, artifact)
2205 && !contains_dynamic(&group)
2206 && !contains_dynamic(&artifact)
2207 {
2208 let evidence = child(&document, project, "artifactId").map_or_else(
2209 || evidence_at(content, project.line),
2210 |node| evidence_at(content, node.line),
2211 );
2212 push_package(
2213 &mut result.packages,
2214 package(
2215 PackageEcosystem::Maven,
2216 format!("{group}:{artifact}"),
2217 version.filter(|value| !contains_dynamic(value)),
2218 path,
2219 evidence,
2220 ),
2221 tracker,
2222 )?;
2223 }
2224 if let Some(dependencies) = child(&document, project, "dependencies") {
2225 collect_maven_dependencies(
2226 path,
2227 content,
2228 &document,
2229 dependencies,
2230 None,
2231 &mut result,
2232 tracker,
2233 )?;
2234 }
2235 if let Some(profiles) = child(&document, project, "profiles") {
2236 for profile in document
2237 .children(profiles)
2238 .filter(|node| node.name == "profile")
2239 {
2240 let profile_id = child_text(&document, profile, "id");
2241 if let Some(dependencies) = child(&document, profile, "dependencies") {
2242 collect_maven_dependencies(
2243 path,
2244 content,
2245 &document,
2246 dependencies,
2247 profile_id
2248 .as_deref()
2249 .map(|id| format!("profile = \"{id}\""))
2250 .as_deref(),
2251 &mut result,
2252 tracker,
2253 )?;
2254 }
2255 }
2256 }
2257 Ok(result)
2258}
2259
2260fn collect_maven_dependencies(
2261 path: &str,
2262 content: &str,
2263 document: &XmlDocument,
2264 dependencies: &XmlNode,
2265 profile: Option<&str>,
2266 output: &mut PackageManifest,
2267 tracker: &mut ExtractionTracker,
2268) -> Result<(), PackageManifestError> {
2269 for item in document
2270 .children(dependencies)
2271 .filter(|node| node.name == "dependency")
2272 {
2273 let (Some(group), Some(artifact)) = (
2274 child_text(document, item, "groupId"),
2275 child_text(document, item, "artifactId"),
2276 ) else {
2277 continue;
2278 };
2279 if contains_dynamic(&group) || contains_dynamic(&artifact) {
2280 continue;
2281 }
2282 let version =
2283 child_text(document, item, "version").filter(|value| !contains_dynamic(value));
2284 let declared_scope =
2285 child_text(document, item, "scope").unwrap_or_else(|| "compile".to_owned());
2286 let scope = match declared_scope.as_str() {
2287 "test" => DependencyScope::Test,
2288 "provided" | "system" => DependencyScope::Build,
2289 _ => DependencyScope::Runtime,
2290 };
2291 let optional = child_text(document, item, "optional").is_some_and(|value| value == "true");
2292 let type_condition = child_text(document, item, "type")
2293 .filter(|value| value != "jar")
2294 .map(|value| format!("type = \"{value}\""));
2295 push_dependency(
2296 &mut output.dependencies,
2297 dependency(DependencyInput {
2298 ecosystem: PackageEcosystem::Maven,
2299 name: format!("{group}:{artifact}"),
2300 version,
2301 scope: if optional {
2302 DependencyScope::Optional
2303 } else {
2304 scope
2305 },
2306 optional,
2307 condition: join_conditions(profile.map(str::to_owned), type_condition),
2308 path,
2309 evidence: evidence_at(content, item.line),
2310 confidence: EXACT_CONFIDENCE,
2311 }),
2312 tracker,
2313 )?;
2314 }
2315 Ok(())
2316}
2317
2318fn parse_gradle(
2319 path: &str,
2320 content: &str,
2321 tracker: &mut ExtractionTracker,
2322) -> Result<PackageManifest, PackageManifestError> {
2323 reject_nul(path, content)?;
2324 validate_gradle_balance(path, content, tracker)?;
2325 let mut result = PackageManifest::default();
2326 for (index, line) in content.lines().enumerate() {
2327 if index.is_multiple_of(1_024) {
2328 tracker.check_structured_time()?;
2329 }
2330 let line_number = index + 1;
2331 let trimmed = line.trim();
2332 if trimmed.starts_with("//") || trimmed.is_empty() {
2333 continue;
2334 }
2335 let Some((configuration, argument)) = parse_gradle_declaration(trimmed) else {
2336 continue;
2337 };
2338 let Some((group, artifact, version)) = parse_gradle_coordinate(argument) else {
2339 continue;
2340 };
2341 let (scope, optional) = gradle_scope(configuration);
2342 push_dependency(
2343 &mut result.dependencies,
2344 dependency(DependencyInput {
2345 ecosystem: PackageEcosystem::Gradle,
2346 name: format!("{group}:{artifact}"),
2347 version,
2348 scope,
2349 optional,
2350 condition: Some(format!("configuration = \"{configuration}\"")),
2351 path,
2352 evidence: evidence_at(content, line_number),
2353 confidence: STATIC_TEXT_CONFIDENCE,
2354 }),
2355 tracker,
2356 )?;
2357 }
2358 Ok(result)
2359}
2360
2361fn validate_gradle_balance(
2362 path: &str,
2363 content: &str,
2364 tracker: &ExtractionTracker,
2365) -> Result<(), PackageManifestError> {
2366 let mut curly = 0_i32;
2367 let mut round = 0_i32;
2368 let mut quote = None;
2369 let mut escaped = false;
2370 for (index, character) in content.chars().enumerate() {
2371 if index.is_multiple_of(1_024) {
2372 tracker.check_structured_time()?;
2373 }
2374 if escaped {
2375 escaped = false;
2376 continue;
2377 }
2378 if character == '\\' && quote.is_some() {
2379 escaped = true;
2380 continue;
2381 }
2382 if character == '\'' || character == '"' {
2383 if quote == Some(character) {
2384 quote = None;
2385 } else if quote.is_none() {
2386 quote = Some(character);
2387 }
2388 continue;
2389 }
2390 if quote.is_some() {
2391 continue;
2392 }
2393 match character {
2394 '{' => curly += 1,
2395 '}' => curly -= 1,
2396 '(' => round += 1,
2397 ')' => round -= 1,
2398 _ => {}
2399 }
2400 if curly < 0 || round < 0 {
2401 return Err(malformed(path, "unbalanced Gradle delimiters"));
2402 }
2403 }
2404 if curly != 0 || round != 0 || quote.is_some() {
2405 return Err(malformed(path, "unbalanced Gradle delimiters or quotes"));
2406 }
2407 Ok(())
2408}
2409
2410fn parse_gradle_declaration(line: &str) -> Option<(&str, &str)> {
2411 let configuration_end = line
2412 .char_indices()
2413 .find_map(|(index, character)| {
2414 (character.is_whitespace() || character == '(').then_some(index)
2415 })
2416 .unwrap_or(line.len());
2417 let configuration = &line[..configuration_end];
2418 if !matches!(
2419 configuration,
2420 "api"
2421 | "implementation"
2422 | "runtimeOnly"
2423 | "compileOnly"
2424 | "testImplementation"
2425 | "testRuntimeOnly"
2426 | "testCompileOnly"
2427 | "developmentOnly"
2428 | "annotationProcessor"
2429 | "kapt"
2430 | "classpath"
2431 ) {
2432 return None;
2433 }
2434 let rest = line[configuration_end..].trim();
2435 let argument = if rest.starts_with('(') && rest.ends_with(')') {
2436 rest[1..rest.len() - 1].trim()
2437 } else {
2438 rest
2439 };
2440 Some((configuration, argument))
2441}
2442
2443fn parse_gradle_coordinate(argument: &str) -> Option<(&str, &str, Option<String>)> {
2444 let literal = argument
2445 .strip_prefix('"')
2446 .and_then(|value| value.strip_suffix('"'))
2447 .or_else(|| {
2448 argument
2449 .strip_prefix('\'')
2450 .and_then(|value| value.strip_suffix('\''))
2451 })?;
2452 if contains_dynamic(literal) {
2453 return None;
2454 }
2455 let mut parts = literal.split(':');
2456 let group = parts.next()?;
2457 let artifact = parts.next()?;
2458 let version = parts.next().map(str::to_owned);
2459 if group.is_empty() || artifact.is_empty() || parts.next().is_some() {
2460 return None;
2461 }
2462 Some((group, artifact, version))
2463}
2464
2465fn gradle_scope(configuration: &str) -> (DependencyScope, bool) {
2466 if configuration.starts_with("test") {
2467 (DependencyScope::Test, false)
2468 } else if matches!(configuration, "annotationProcessor" | "kapt" | "classpath") {
2469 (DependencyScope::Build, false)
2470 } else if matches!(configuration, "compileOnly" | "developmentOnly") {
2471 (DependencyScope::Dev, false)
2472 } else {
2473 (DependencyScope::Runtime, false)
2474 }
2475}
2476
2477fn parse_packages_config(
2478 path: &str,
2479 content: &str,
2480 tracker: &mut ExtractionTracker,
2481) -> Result<PackageManifest, PackageManifestError> {
2482 let document = parse_xml(path, content, tracker)?;
2483 let packages = document
2484 .children(document.node(0))
2485 .find(|node| node.name == "packages")
2486 .ok_or_else(|| malformed(path, "missing `packages` root element"))?;
2487 let mut result = PackageManifest::default();
2488 for item in document
2489 .children(packages)
2490 .filter(|node| node.name == "package")
2491 {
2492 let Some(name) = item.attributes.get("id") else {
2493 return Err(malformed(path, "`package` requires an `id` attribute"));
2494 };
2495 let version = item.attributes.get("version").cloned();
2496 let development = item
2497 .attributes
2498 .get("developmentDependency")
2499 .is_some_and(|value| value.eq_ignore_ascii_case("true"));
2500 push_dependency(
2501 &mut result.dependencies,
2502 dependency(DependencyInput {
2503 ecosystem: PackageEcosystem::NuGet,
2504 name: name.clone(),
2505 version,
2506 scope: if development {
2507 DependencyScope::Dev
2508 } else {
2509 DependencyScope::Runtime
2510 },
2511 optional: false,
2512 condition: item
2513 .attributes
2514 .get("targetFramework")
2515 .map(|value| format!("targetFramework = \"{value}\"")),
2516 path,
2517 evidence: evidence_at(content, item.line),
2518 confidence: EXACT_CONFIDENCE,
2519 }),
2520 tracker,
2521 )?;
2522 }
2523 Ok(result)
2524}
2525
2526fn parse_csproj(
2527 path: &str,
2528 content: &str,
2529 tracker: &mut ExtractionTracker,
2530) -> Result<PackageManifest, PackageManifestError> {
2531 let document = parse_xml(path, content, tracker)?;
2532 let project = document
2533 .children(document.node(0))
2534 .find(|node| node.name == "Project")
2535 .ok_or_else(|| malformed(path, "missing `Project` root element"))?;
2536 let mut result = PackageManifest::default();
2537 if let Some(identity) = csproj_package_identity(path, &document, project)? {
2538 push_package(
2539 &mut result.packages,
2540 package(
2541 PackageEcosystem::NuGet,
2542 identity.name,
2543 identity.version,
2544 path,
2545 evidence_at(content, identity.name_node.line),
2546 ),
2547 tracker,
2548 )?;
2549 }
2550 for group in document
2551 .children(project)
2552 .filter(|node| node.name == "ItemGroup")
2553 {
2554 let group_condition = group.attributes.get("Condition").cloned();
2555 for reference in document
2556 .children(group)
2557 .filter(|node| node.name == "PackageReference")
2558 {
2559 let Some(name) = reference
2560 .attributes
2561 .get("Include")
2562 .or_else(|| reference.attributes.get("Update"))
2563 else {
2564 return Err(malformed(
2565 path,
2566 "`PackageReference` requires `Include` or `Update`",
2567 ));
2568 };
2569 if contains_dynamic(name) {
2570 continue;
2571 }
2572 let version = reference
2573 .attributes
2574 .get("Version")
2575 .cloned()
2576 .or_else(|| child_text(&document, reference, "Version"))
2577 .filter(|value| !contains_dynamic(value));
2578 let private_assets = reference
2579 .attributes
2580 .get("PrivateAssets")
2581 .cloned()
2582 .or_else(|| child_text(&document, reference, "PrivateAssets"));
2583 let reference_condition = reference.attributes.get("Condition").cloned();
2584 push_dependency(
2585 &mut result.dependencies,
2586 dependency(DependencyInput {
2587 ecosystem: PackageEcosystem::NuGet,
2588 name: name.clone(),
2589 version,
2590 scope: DependencyScope::Runtime,
2591 optional: false,
2592 condition: join_conditions(group_condition.clone(), reference_condition),
2593 path,
2594 evidence: evidence_at(content, reference.line),
2595 confidence: if private_assets.as_deref() == Some("all") {
2596 STATIC_TEXT_CONFIDENCE
2597 } else {
2598 EXACT_CONFIDENCE
2599 },
2600 }),
2601 tracker,
2602 )?;
2603 }
2604 }
2605 Ok(result)
2606}
2607
2608struct CsprojPackageIdentity<'a> {
2609 name_node: &'a XmlNode,
2610 name: String,
2611 version: Option<String>,
2612}
2613
2614fn csproj_package_identity<'a>(
2615 path: &str,
2616 document: &'a XmlDocument,
2617 project: &'a XmlNode,
2618) -> Result<Option<CsprojPackageIdentity<'a>>, PackageManifestError> {
2619 let mut identities = Vec::new();
2620 for group in document
2621 .children(project)
2622 .filter(|node| node.name == "PropertyGroup")
2623 {
2624 let Some(name_node) = child(document, group, "PackageId") else {
2625 continue;
2626 };
2627 let name = decode_xml_entities(name_node.text.trim());
2628 if name.is_empty() || contains_dynamic(&name) {
2629 continue;
2630 }
2631 let version = ["PackageVersion", "Version", "VersionPrefix"]
2632 .into_iter()
2633 .find_map(|field| child_text(document, group, field))
2634 .filter(|value| !contains_dynamic(value));
2635 identities.push(CsprojPackageIdentity {
2636 name_node,
2637 name,
2638 version,
2639 });
2640 }
2641 identities.sort_by(|left, right| left.name.cmp(&right.name));
2642 identities.dedup_by(|left, right| left.name == right.name && left.version == right.version);
2643 if identities.len() > 1 {
2644 return Err(malformed(
2645 path,
2646 "multiple distinct static `PackageId` declarations are ambiguous",
2647 ));
2648 }
2649 Ok(identities.pop())
2650}
2651
2652fn contains_dynamic(value: &str) -> bool {
2653 value.contains("${")
2654 || value.contains("$(")
2655 || value.contains('$')
2656 || value.contains("#{")
2657 || value.contains("{{")
2658}
2659
2660fn join_conditions(left: Option<String>, right: Option<String>) -> Option<String> {
2661 match (left, right) {
2662 (Some(left), Some(right)) => Some(format!("{left} and {right}")),
2663 (Some(value), None) | (None, Some(value)) => Some(value),
2664 (None, None) => None,
2665 }
2666}
2667
2668fn reject_nul(path: &str, content: &str) -> Result<(), PackageManifestError> {
2669 if content.contains('\0') {
2670 return Err(malformed(path, "input contains a NUL byte"));
2671 }
2672 Ok(())
2673}
2674
2675#[cfg(test)]
2676mod tests {
2677 use std::fmt::Write as _;
2678 use std::sync::atomic::{AtomicU64, Ordering};
2679 use std::time::Duration;
2680
2681 use super::*;
2682 use crate::{ExtractionClock, ExtractionResource};
2683
2684 #[derive(Debug)]
2685 struct FixedClock(Duration);
2686
2687 impl ExtractionClock for FixedClock {
2688 fn elapsed(&self) -> Duration {
2689 self.0
2690 }
2691 }
2692
2693 #[derive(Debug)]
2694 struct AdvancingClock(AtomicU64);
2695
2696 impl ExtractionClock for AdvancingClock {
2697 fn elapsed(&self) -> Duration {
2698 Duration::from_millis(self.0.fetch_add(2, Ordering::Relaxed))
2699 }
2700 }
2701
2702 fn extract(path: &str, source: &str) -> PackageManifest {
2703 extract_package_manifest(path, source).expect("fixture should parse")
2704 }
2705
2706 fn nested_maven(depth: usize) -> String {
2707 let mut source = String::from("<project>");
2708 source.push_str(&"<level>".repeat(depth.saturating_sub(1)));
2709 source.push_str(&"</level>".repeat(depth.saturating_sub(1)));
2710 source.push_str("</project>");
2711 source
2712 }
2713
2714 #[test]
2715 fn package_facts_should_honor_work_observation_and_identifier_budgets() {
2716 let work_budgets = ExtractionBudgets {
2717 max_work_units_per_artifact: 1,
2718 ..ExtractionBudgets::default()
2719 };
2720 let mut work = ExtractionTracker::new("package.json", "packages", &work_budgets);
2721 assert!(matches!(
2722 extract_package_manifest_with_tracker("package.json", "{}", &mut work),
2723 Err(PackageManifestError::LimitExceeded(error))
2724 if error.resource == ExtractionResource::WorkUnits
2725 && error.observed == 2
2726 && error.maximum == 1
2727 ));
2728
2729 let observation_budgets = ExtractionBudgets {
2730 max_observations_per_artifact: 1,
2731 ..ExtractionBudgets::default()
2732 };
2733 let mut observations =
2734 ExtractionTracker::new("package.json", "packages", &observation_budgets);
2735 assert!(matches!(
2736 extract_package_manifest_with_tracker(
2737 "package.json",
2738 r#"{"name":"service","dependencies":{"serde":"1"}}"#,
2739 &mut observations,
2740 ),
2741 Err(PackageManifestError::LimitExceeded(error))
2742 if error.resource == ExtractionResource::Observations
2743 && error.observed == 2
2744 && error.maximum == 1
2745 ));
2746
2747 let exact_observation_budgets = ExtractionBudgets {
2748 max_observations_per_artifact: 2,
2749 ..ExtractionBudgets::default()
2750 };
2751 let mut exact_observations =
2752 ExtractionTracker::new("package.json", "packages", &exact_observation_budgets);
2753 assert!(
2754 extract_package_manifest_with_tracker(
2755 "package.json",
2756 r#"{"name":"service","dependencies":{"serde":"1"}}"#,
2757 &mut exact_observations,
2758 )
2759 .is_ok()
2760 );
2761
2762 let identifier_budgets = ExtractionBudgets {
2763 max_identifier_bytes_per_value: 4,
2764 ..ExtractionBudgets::default()
2765 };
2766 let mut identifier =
2767 ExtractionTracker::new("package.json", "packages", &identifier_budgets);
2768 assert!(matches!(
2769 extract_package_manifest_with_tracker(
2770 "package.json",
2771 r#"{"name":"service"}"#,
2772 &mut identifier,
2773 ),
2774 Err(PackageManifestError::LimitExceeded(error))
2775 if error.resource == ExtractionResource::IdentifierBytesPerValue
2776 && error.maximum == 4
2777 ));
2778 }
2779
2780 #[test]
2781 fn package_json_should_reject_depth_65_before_dom_materialization() {
2782 let budgets = ExtractionBudgets {
2783 max_structural_depth_per_artifact: 64,
2784 ..ExtractionBudgets::default()
2785 };
2786 let exact_source = format!("{}0{}", "[".repeat(64), "]".repeat(64));
2787 let mut exact = ExtractionTracker::new("package.json", "packages", &budgets);
2788 assert!(!matches!(
2789 extract_package_manifest_with_tracker("package.json", &exact_source, &mut exact),
2790 Err(PackageManifestError::LimitExceeded(error))
2791 if error.resource == ExtractionResource::StructuralDepth
2792 ));
2793
2794 let above_source = format!("{}0{}", "[".repeat(65), "]".repeat(65));
2795 let mut above = ExtractionTracker::new("package.json", "packages", &budgets);
2796 assert!(matches!(
2797 extract_package_manifest_with_tracker("package.json", &above_source, &mut above),
2798 Err(PackageManifestError::LimitExceeded(error))
2799 if error.resource == ExtractionResource::StructuralDepth
2800 && error.observed == 65
2801 && error.maximum == 64
2802 ));
2803 }
2804
2805 #[test]
2806 fn xml_depth_should_accept_64_and_reject_65_with_an_iterative_stack() {
2807 let budgets = ExtractionBudgets {
2808 max_structural_depth_per_artifact: 64,
2809 ..ExtractionBudgets::default()
2810 };
2811 let mut exact = ExtractionTracker::new("pom.xml", "packages", &budgets);
2812 let mut above = ExtractionTracker::new("pom.xml", "packages", &budgets);
2813
2814 assert!(
2815 extract_package_manifest_with_tracker("pom.xml", &nested_maven(64), &mut exact).is_ok()
2816 );
2817 assert!(matches!(
2818 extract_package_manifest_with_tracker("pom.xml", &nested_maven(65), &mut above),
2819 Err(PackageManifestError::LimitExceeded(error))
2820 if error.resource == ExtractionResource::StructuralDepth
2821 && error.observed == 65
2822 && error.maximum == 64
2823 ));
2824 }
2825
2826 #[test]
2827 fn short_package_extraction_should_check_its_final_deadline() {
2828 let budgets = ExtractionBudgets {
2829 max_structured_wall_time_ms_per_artifact: 1,
2830 ..ExtractionBudgets::default()
2831 };
2832 let mut tracker = ExtractionTracker::with_clock(
2833 "Cargo.toml",
2834 "packages",
2835 &budgets,
2836 Box::new(FixedClock(Duration::from_millis(2))),
2837 );
2838
2839 assert!(matches!(
2840 extract_package_manifest_with_tracker("Cargo.toml", "", &mut tracker),
2841 Err(PackageManifestError::LimitExceeded(error))
2842 if error.resource == ExtractionResource::StructuredWallTimeMs
2843 ));
2844 }
2845
2846 #[test]
2847 fn line_parser_should_check_deadline_during_materialization() {
2848 let budgets = ExtractionBudgets {
2849 max_structured_wall_time_ms_per_artifact: 1,
2850 ..ExtractionBudgets::default()
2851 };
2852 let mut tracker = ExtractionTracker::with_clock(
2853 "requirements.txt",
2854 "packages",
2855 &budgets,
2856 Box::new(AdvancingClock(AtomicU64::new(0))),
2857 );
2858 let source = "dep==1\n".repeat(1_025);
2859
2860 assert!(matches!(
2861 parse_requirements("requirements.txt", &source, &mut tracker),
2862 Err(PackageManifestError::LimitExceeded(error))
2863 if error.resource == ExtractionResource::StructuredWallTimeMs
2864 ));
2865 }
2866
2867 #[test]
2868 fn xml_parser_should_track_lines_without_prefix_rescans() {
2869 let mut source = String::from("<packages>\n");
2870 for index in 0..5_000 {
2871 writeln!(source, "<package id=\"p{index}\" version=\"1\"/>")
2872 .expect("String writes are infallible");
2873 }
2874 source.push_str("</packages>\n");
2875 let budgets = ExtractionBudgets::default();
2876 let mut tracker = ExtractionTracker::new("packages.config", "packages", &budgets);
2877 let document = parse_xml("packages.config", &source, &mut tracker).expect("bounded XML");
2878 let last_package = document
2879 .nodes
2880 .iter()
2881 .rev()
2882 .find(|node| node.name == "package")
2883 .expect("last package");
2884
2885 assert_eq!(last_package.line, 5_001);
2886 }
2887
2888 #[test]
2889 fn package_json_preserves_scopes_workspaces_exports_and_lines() {
2890 let source = r#"{
2891 "name": "@acme/web",
2892 "version": "1.2.3",
2893 "dependencies": {"zod": "^3.0.0"},
2894 "devDependencies": {"vitest": "~2.0"},
2895 "peerDependencies": {"react": ">=18"},
2896 "peerDependenciesMeta": {"react": {"optional": true}},
2897 "optionalDependencies": {"fsevents": "2.3.3"},
2898 "workspaces": ["apps/*", "packages/*"],
2899 "exports": {".": "./index.js", "./cli": "./cli.js"}
2900}"#;
2901 let result = extract("web/package.json", source);
2902
2903 assert_eq!(
2904 (
2905 result.packages[0].name.as_str(),
2906 result
2907 .dependencies
2908 .iter()
2909 .map(|item| (item.name.as_str(), item.scope, item.optional))
2910 .collect::<Vec<_>>(),
2911 result
2912 .workspace_members
2913 .iter()
2914 .map(|item| item.value.as_str())
2915 .collect::<Vec<_>>(),
2916 result
2917 .exports
2918 .iter()
2919 .map(|item| item.value.as_str())
2920 .collect::<Vec<_>>(),
2921 result.dependencies[0].evidence.line,
2922 ),
2923 (
2924 "@acme/web",
2925 vec![
2926 ("fsevents", DependencyScope::Optional, true),
2927 ("react", DependencyScope::Peer, true),
2928 ("vitest", DependencyScope::Dev, false),
2929 ("zod", DependencyScope::Runtime, false),
2930 ],
2931 vec!["apps/*", "packages/*"],
2932 vec![".", "./cli"],
2933 8,
2934 )
2935 );
2936 }
2937
2938 #[test]
2939 fn npm_lockfiles_emit_presence_without_transitive_dependencies() {
2940 let source = r#"{"name":"app","lockfileVersion":3,"packages":{"":{"name":"app"}}}"#;
2941 let result = extract("package-lock.json", source);
2942
2943 assert_eq!(
2944 (
2945 result.lockfiles[0].package_manager.as_str(),
2946 result.lockfiles[0].format_version.as_deref(),
2947 result.dependencies.len(),
2948 ),
2949 ("npm", Some("3"), 0)
2950 );
2951 }
2952
2953 #[test]
2954 fn cargo_lockfile_emits_presence_without_transitive_dependencies() {
2955 let source = "# This file is automatically @generated by Cargo.\nversion = 4\n\n[[package]]\nname = \"api\"\nversion = \"1.0.0\"\n";
2956 let result = extract("Cargo.lock", source);
2957
2958 assert_eq!(
2959 (
2960 result.lockfiles[0].ecosystem,
2961 result.lockfiles[0].package_manager.as_str(),
2962 result.lockfiles[0].format_version.as_deref(),
2963 result.dependencies.len(),
2964 ),
2965 (PackageEcosystem::Cargo, "cargo", Some("4"), 0)
2966 );
2967 }
2968
2969 #[test]
2970 fn pnpm_and_yarn_lockfiles_preserve_format_metadata() {
2971 let pnpm = extract("pnpm-lock.yaml", "lockfileVersion: '9.0'\nimporters: {}\n");
2972 let yarn = extract("yarn.lock", "__metadata:\n version: 8\n cacheKey: 10c0\n");
2973
2974 assert_eq!(
2975 (
2976 pnpm.lockfiles[0].format_version.as_deref(),
2977 yarn.lockfiles[0].format_version.as_deref(),
2978 ),
2979 (Some("9.0"), Some("8"))
2980 );
2981 }
2982
2983 #[test]
2984 fn pyproject_extracts_pep621_poetry_groups_markers_and_optional_flags() {
2985 let source = r#"[project]
2986name = "service"
2987version = "2.0.0"
2988dependencies = [
2989 "httpx>=0.27; python_version >= '3.11'",
2990]
2991[project.optional-dependencies]
2992docs = ["sphinx~=7.0"]
2993[tool.poetry.dependencies]
2994orjson = { version = "^3.10", optional = true, markers = "platform_python_implementation == 'CPython'" }
2995[tool.poetry.group.test.dependencies]
2996pytest = "^8.0"
2997"#;
2998 let result = extract("pyproject.toml", source);
2999
3000 assert_eq!(
3001 result
3002 .dependencies
3003 .iter()
3004 .map(|item| (
3005 item.name.as_str(),
3006 item.scope,
3007 item.optional,
3008 item.condition.as_deref()
3009 ))
3010 .collect::<Vec<_>>(),
3011 vec![
3012 (
3013 "httpx",
3014 DependencyScope::Runtime,
3015 false,
3016 Some("python_version >= '3.11'")
3017 ),
3018 (
3019 "orjson",
3020 DependencyScope::Optional,
3021 true,
3022 Some("markers = \"platform_python_implementation == 'CPython'\"")
3023 ),
3024 (
3025 "pytest",
3026 DependencyScope::Test,
3027 false,
3028 Some("poetry group = \"test\"")
3029 ),
3030 (
3031 "sphinx",
3032 DependencyScope::Optional,
3033 true,
3034 Some("extra = \"docs\"")
3035 ),
3036 ]
3037 );
3038 }
3039
3040 #[test]
3041 fn requirements_preserve_markers_and_ignore_includes_and_dynamic_urls() {
3042 let source = "# pinned\nrequests>=2.32 ; python_version >= \"3.10\"\n-r base.txt\npkg @ git+https://example.invalid/pkg\n";
3043 let result = extract("config/requirements-dev.txt", source);
3044
3045 assert_eq!(
3046 (
3047 result.dependencies.len(),
3048 result.dependencies[0].name.as_str(),
3049 result.dependencies[0].version_or_range.as_deref(),
3050 result.dependencies[0].condition.as_deref(),
3051 result.dependencies[0].evidence.line,
3052 ),
3053 (
3054 1,
3055 "requests",
3056 Some(">=2.32"),
3057 Some("python_version >= \"3.10\""),
3058 2,
3059 )
3060 );
3061 }
3062
3063 #[test]
3064 fn cargo_extracts_scopes_targets_features_renames_and_workspace_members() {
3065 let source = r#"[package]
3066name = "engine"
3067version = "0.4.0"
3068[dependencies]
3069serde = "1"
3070wire = { package = "prost", version = "0.13", optional = true }
3071[dev-dependencies]
3072insta = "1"
3073[build-dependencies]
3074cc = "1"
3075[target.'cfg(unix)'.dependencies]
3076nix = "0.29"
3077[features]
3078grpc = ["dep:wire"]
3079[workspace]
3080members = ["api", "worker"]
3081"#;
3082 let result = extract("Cargo.toml", source);
3083
3084 assert_eq!(
3085 (
3086 result
3087 .dependencies
3088 .iter()
3089 .map(|item| (
3090 item.name.as_str(),
3091 item.scope,
3092 item.optional,
3093 item.condition.as_deref()
3094 ))
3095 .collect::<Vec<_>>(),
3096 result.features[0].value.as_str(),
3097 result
3098 .workspace_members
3099 .iter()
3100 .map(|item| item.value.as_str())
3101 .collect::<Vec<_>>(),
3102 ),
3103 (
3104 vec![
3105 ("cc", DependencyScope::Build, false, None),
3106 ("insta", DependencyScope::Dev, false, None),
3107 (
3108 "nix",
3109 DependencyScope::Runtime,
3110 false,
3111 Some("target = cfg(unix)")
3112 ),
3113 (
3114 "prost",
3115 DependencyScope::Runtime,
3116 true,
3117 Some("feature = \"grpc\"")
3118 ),
3119 ("serde", DependencyScope::Runtime, false, None),
3120 ],
3121 "grpc",
3122 vec!["api", "worker"],
3123 )
3124 );
3125 }
3126
3127 #[test]
3128 fn cargo_preserves_sqlx_backend_and_capability_features() {
3129 let source = r#"
3130[package]
3131name = "api"
3132version = "1.0.0"
3133
3134[dependencies]
3135sqlx = { version = "0.8", features = ["postgres", "macros", "migrate", "postgres"] }
3136"#;
3137 let result = extract("Cargo.toml", source);
3138 let sqlx = result
3139 .dependencies
3140 .iter()
3141 .find(|dependency| dependency.name == "sqlx")
3142 .expect("SQLx dependency");
3143
3144 assert_eq!(
3145 (
3146 sqlx.version_or_range.as_deref(),
3147 sqlx.scope,
3148 sqlx.condition.as_deref(),
3149 ),
3150 (
3151 Some("0.8"),
3152 DependencyScope::Runtime,
3153 Some("dependency features = \"macros|migrate|postgres\""),
3154 )
3155 );
3156 }
3157
3158 #[test]
3159 fn cargo_preserves_mysql_async_transport_and_mapping_features() {
3160 let source = r#"
3161[package]
3162name = "api"
3163version = "1.0.0"
3164
3165[dependencies]
3166mysql_async = { version = "0.37", default-features = false, features = ["minimal-rust", "rustls-tls", "ring", "derive"] }
3167"#;
3168 let result = extract("Cargo.toml", source);
3169 let mysql_async = result
3170 .dependencies
3171 .iter()
3172 .find(|dependency| dependency.name == "mysql_async")
3173 .expect("mysql_async dependency");
3174
3175 assert_eq!(
3176 (
3177 mysql_async.version_or_range.as_deref(),
3178 mysql_async.scope,
3179 mysql_async.condition.as_deref(),
3180 ),
3181 (
3182 Some("0.37"),
3183 DependencyScope::Runtime,
3184 Some("dependency features = \"derive|minimal-rust|ring|rustls-tls\""),
3185 )
3186 );
3187 }
3188
3189 #[test]
3190 fn go_mod_preserves_indirect_condition_and_go_work_members() {
3191 let module = extract(
3192 "go.mod",
3193 "module example.com/service\n\ngo 1.23\nrequire (\n example.com/a v1.2.0\n example.com/b v2.0.0 // indirect\n)\n",
3194 );
3195 let workspace = extract("go.work", "go 1.23\nuse (\n ./api\n ./worker\n)\n");
3196
3197 assert_eq!(
3198 (
3199 module.packages[0].name.as_str(),
3200 module.dependencies[1].optional,
3201 module.dependencies[1].condition.as_deref(),
3202 workspace
3203 .workspace_members
3204 .iter()
3205 .map(|item| item.value.as_str())
3206 .collect::<Vec<_>>(),
3207 ),
3208 (
3209 "example.com/service",
3210 true,
3211 Some("indirect"),
3212 vec!["./api", "./worker"],
3213 )
3214 );
3215 }
3216
3217 #[test]
3218 fn maven_extracts_static_coordinates_scopes_optional_and_profiles() {
3219 let source = r"<project>
3220 <parent><groupId>com.acme</groupId><version>1.0</version></parent>
3221 <artifactId>service</artifactId>
3222 <dependencies>
3223 <dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId><version>2.0.16</version></dependency>
3224 <dependency><groupId>junit</groupId><artifactId>junit</artifactId><scope>test</scope><optional>true</optional></dependency>
3225 <dependency><groupId>${dynamic.group}</groupId><artifactId>ignored</artifactId></dependency>
3226 </dependencies>
3227 <profiles><profile><id>native</id><dependencies>
3228 <dependency><groupId>org.graalvm</groupId><artifactId>native-image</artifactId><version>24</version></dependency>
3229 </dependencies></profile></profiles>
3230</project>";
3231 let result = extract("pom.xml", source);
3232
3233 assert_eq!(
3234 (
3235 result.packages[0].name.as_str(),
3236 result
3237 .dependencies
3238 .iter()
3239 .map(|item| (
3240 item.name.as_str(),
3241 item.scope,
3242 item.optional,
3243 item.condition.as_deref()
3244 ))
3245 .collect::<Vec<_>>(),
3246 ),
3247 (
3248 "com.acme:service",
3249 vec![
3250 ("junit:junit", DependencyScope::Optional, true, None),
3251 (
3252 "org.graalvm:native-image",
3253 DependencyScope::Runtime,
3254 false,
3255 Some("profile = \"native\"")
3256 ),
3257 ("org.slf4j:slf4j-api", DependencyScope::Runtime, false, None),
3258 ],
3259 )
3260 );
3261 }
3262
3263 #[test]
3264 fn gradle_extracts_only_literal_coordinates_with_configuration_conditions() {
3265 let source = r#"dependencies {
3266 implementation("com.squareup.okhttp3:okhttp:4.12.0")
3267 testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
3268 implementation(libs.jackson)
3269 annotationProcessor("org.example:processor:1.0")
3270}"#;
3271 let result = extract("app/build.gradle.kts", source);
3272
3273 assert_eq!(
3274 result
3275 .dependencies
3276 .iter()
3277 .map(|item| (item.name.as_str(), item.scope, item.condition.as_deref()))
3278 .collect::<Vec<_>>(),
3279 vec![
3280 (
3281 "com.squareup.okhttp3:okhttp",
3282 DependencyScope::Runtime,
3283 Some("configuration = \"implementation\"")
3284 ),
3285 (
3286 "org.example:processor",
3287 DependencyScope::Build,
3288 Some("configuration = \"annotationProcessor\"")
3289 ),
3290 (
3291 "org.junit.jupiter:junit-jupiter",
3292 DependencyScope::Test,
3293 Some("configuration = \"testImplementation\"")
3294 ),
3295 ]
3296 );
3297 }
3298
3299 #[test]
3300 fn nuget_extracts_packages_config_and_sdk_package_references() {
3301 let packages_config = extract(
3302 "packages.config",
3303 r#"<packages>
3304 <package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
3305 <package id="NUnit" version="4.2.2" developmentDependency="true" />
3306</packages>"#,
3307 );
3308 let sdk = extract(
3309 "src/App.csproj",
3310 r#"<Project Sdk="Microsoft.NET.Sdk">
3311 <PropertyGroup><PackageId>Acme.App</PackageId><Version>1.4.0</Version></PropertyGroup>
3312 <ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
3313 <PackageReference Include="Serilog" Version="4.0.0" />
3314 <PackageReference Include="Dynamic" Version="$(DynamicVersion)" />
3315 </ItemGroup>
3316</Project>"#,
3317 );
3318
3319 assert_eq!(
3320 (
3321 packages_config
3322 .dependencies
3323 .iter()
3324 .map(|item| (item.name.as_str(), item.scope, item.condition.as_deref()))
3325 .collect::<Vec<_>>(),
3326 sdk.dependencies[0].name.as_str(),
3327 sdk.dependencies[0].condition.as_deref(),
3328 sdk.dependencies[1].version_or_range.as_deref(),
3329 sdk.packages
3330 .first()
3331 .map(|item| (item.name.as_str(), item.version.as_deref())),
3332 ),
3333 (
3334 vec![
3335 ("NUnit", DependencyScope::Dev, None),
3336 (
3337 "Newtonsoft.Json",
3338 DependencyScope::Runtime,
3339 Some("targetFramework = \"net48\"")
3340 ),
3341 ],
3342 "Dynamic",
3343 Some("'$(TargetFramework)' == 'net8.0'"),
3344 Some("4.0.0"),
3345 Some(("Acme.App", Some("1.4.0"))),
3346 )
3347 );
3348 }
3349
3350 #[test]
3351 fn malformed_structured_inputs_are_rejected() {
3352 let cases = [
3353 ("package.json", r#"{"dependencies": []}"#),
3354 ("pyproject.toml", "[project\nname = \"bad\""),
3355 ("Cargo.toml", "[dependencies]\nserde = { version = \"1\""),
3356 ("go.mod", "module example.com/a\nrequire (\na v1\n"),
3357 ("pom.xml", "<project><artifactId>x</project>"),
3358 ("build.gradle", "dependencies { implementation(\"a:b:1\")"),
3359 ("packages.config", "<packages><package /></packages>"),
3360 ("app.csproj", "<Project><ItemGroup></Project>"),
3361 ];
3362
3363 for (path, source) in cases {
3364 assert!(
3365 matches!(
3366 extract_package_manifest(path, source),
3367 Err(PackageManifestError::Malformed { .. })
3368 ),
3369 "{path} should reject malformed input"
3370 );
3371 }
3372 }
3373
3374 #[test]
3375 fn output_order_is_deterministic_across_declaration_order() {
3376 let first = extract(
3377 "package.json",
3378 r#"{"name":"x","dependencies":{"z":"1","a":"2"},"devDependencies":{"m":"3"}}"#,
3379 );
3380 let second = extract(
3381 "package.json",
3382 r#"{"devDependencies":{"m":"3"},"dependencies":{"a":"2","z":"1"},"name":"x"}"#,
3383 );
3384
3385 assert_eq!(
3386 first
3387 .dependencies
3388 .iter()
3389 .map(|item| (&item.name, item.scope, &item.version_or_range))
3390 .collect::<Vec<_>>(),
3391 second
3392 .dependencies
3393 .iter()
3394 .map(|item| (&item.name, item.scope, &item.version_or_range))
3395 .collect::<Vec<_>>()
3396 );
3397 }
3398
3399 #[test]
3400 fn unsupported_and_non_relative_paths_are_rejected() {
3401 for path in [
3402 "/package.json",
3403 "../package.json",
3404 "README.md",
3405 "C:\\package.json",
3406 ] {
3407 assert!(
3408 matches!(
3409 extract_package_manifest(path, "{}"),
3410 Err(PackageManifestError::UnsupportedPath(_))
3411 ),
3412 "{path} should be unsupported"
3413 );
3414 }
3415 }
3416}