provenant/parsers/
yarn_pnp.rs1use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
8use crate::parser_warn as warn;
9use crate::parsers::utils::{capped_iteration_limit, npm_purl, truncate_field};
10
11use super::PackageParser;
12
13pub struct YarnPnpParser;
14
15impl PackageParser for YarnPnpParser {
16 const PACKAGE_TYPE: PackageType = PackageType::Npm;
17
18 fn is_match(path: &Path) -> bool {
19 path.file_name().and_then(|name| name.to_str()) == Some(".pnp.cjs")
20 }
21
22 fn extract_packages(path: &Path) -> Vec<PackageData> {
23 let content = match crate::parsers::utils::read_file_to_string(path, None) {
24 Ok(content) => content,
25 Err(error) => {
26 warn!("Failed to read .pnp.cjs at {:?}: {}", path, error);
27 return vec![default_package_data()];
28 }
29 };
30
31 match parse_yarn_pnp(&content) {
32 Ok(package_data) => vec![package_data],
33 Err(error) => {
34 warn!("Failed to parse .pnp.cjs at {:?}: {}", path, error);
35 vec![default_package_data()]
36 }
37 }
38 }
39
40 fn metadata() -> Vec<super::metadata::ParserMetadata> {
41 vec![super::metadata::ParserMetadata {
42 description: "yarn plug and play runtime state",
43 file_patterns: &["**/.pnp.cjs"],
44 package_type: "npm",
45 primary_language: "JavaScript",
46 documentation_url: Some("https://yarnpkg.com/features/pnp"),
47 }]
48 }
49}
50
51fn default_package_data() -> PackageData {
52 PackageData {
53 package_type: Some(YarnPnpParser::PACKAGE_TYPE),
54 primary_language: Some("JavaScript".to_string()),
55 datasource_id: Some(DatasourceId::YarnPnpCjs),
56 ..Default::default()
57 }
58}
59
60fn parse_yarn_pnp(content: &str) -> Result<PackageData, String> {
61 let json_text = extract_raw_runtime_state_json(content)
62 .ok_or_else(|| "RAW_RUNTIME_STATE object not found in .pnp.cjs".to_string())?;
63 let runtime_state: serde_json::Value = serde_json::from_str(json_text)
64 .map_err(|error| format!("invalid RAW_RUNTIME_STATE JSON: {error}"))?;
65
66 let registry_entries = runtime_state
67 .get("packageRegistryData")
68 .and_then(serde_json::Value::as_array)
69 .ok_or_else(|| "packageRegistryData missing from RAW_RUNTIME_STATE".to_string())?;
70
71 let root_refs = registry_entries
72 .iter()
73 .find_map(parse_root_dependency_map)
74 .unwrap_or_default();
75 let mut seen_locators = HashSet::new();
76 let mut dependencies = Vec::new();
77
78 let entries_limit = capped_iteration_limit(registry_entries.len(), "yarn PnP registry entries");
79 for entry in registry_entries.iter().take(entries_limit) {
80 let Some(locator) = entry.get(0).and_then(serde_json::Value::as_str) else {
81 continue;
82 };
83 if !seen_locators.insert(locator.to_string()) {
84 continue;
85 }
86 let Some((name, reference)) = split_locator(locator) else {
87 continue;
88 };
89
90 let version = reference
91 .strip_prefix("npm:")
92 .map(|v| truncate_field(v.to_string()));
93 dependencies.push(Dependency {
94 purl: npm_purl(
95 truncate_field(name.to_string()).as_str(),
96 version.as_deref(),
97 ),
98 extracted_requirement: Some(truncate_field(reference.to_string())),
99 scope: Some("dependencies".to_string()),
100 is_runtime: Some(true),
101 is_optional: Some(false),
102 is_pinned: Some(version.is_some()),
103 is_direct: Some(
104 root_refs
105 .get(name)
106 .is_some_and(|root_ref| root_ref == reference),
107 ),
108 resolved_package: None,
109 extra_data: Some(HashMap::from([(
110 "locator".to_string(),
111 serde_json::Value::String(truncate_field(locator.to_string())),
112 )])),
113 });
114 }
115
116 let mut package = default_package_data();
117 package.dependencies = dependencies;
118 package.extra_data = Some(HashMap::from([(
119 "package_registry_entries".to_string(),
120 serde_json::Value::from(registry_entries.len()),
121 )]));
122 Ok(package)
123}
124
125fn parse_root_dependency_map(entry: &serde_json::Value) -> Option<HashMap<String, String>> {
126 if !entry.get(0).is_some_and(serde_json::Value::is_null) {
127 return None;
128 }
129
130 let dependencies = entry.get(1)?.get("packageDependencies")?;
131 Some(parse_dependency_pairs(dependencies))
132}
133
134fn parse_dependency_pairs(value: &serde_json::Value) -> HashMap<String, String> {
135 if let Some(array) = value.as_array() {
136 let limit = capped_iteration_limit(array.len(), "yarn PnP dependency pairs");
137 return array
138 .iter()
139 .take(limit)
140 .filter_map(|pair| {
141 let pair = pair.as_array()?;
142 let name = pair.first()?.as_str()?;
143 let reference = pair.get(1)?.as_str()?;
144 Some((
145 truncate_field(name.to_string()),
146 truncate_field(reference.to_string()),
147 ))
148 })
149 .collect();
150 }
151
152 let object_limit = capped_iteration_limit(
153 value.as_object().map_or(0, serde_json::Map::len),
154 "yarn PnP dependency object",
155 );
156 value
157 .as_object()
158 .into_iter()
159 .flatten()
160 .take(object_limit)
161 .filter_map(|(name, reference)| {
162 reference.as_str().map(|reference| {
163 (
164 truncate_field(name.clone()),
165 truncate_field(reference.to_string()),
166 )
167 })
168 })
169 .collect()
170}
171
172fn split_locator(locator: &str) -> Option<(&str, &str)> {
173 let split_at = locator.rfind('@')?;
174 if split_at == 0 {
175 return None;
176 }
177 Some((&locator[..split_at], &locator[split_at + 1..]))
178}
179
180fn extract_raw_runtime_state_json(content: &str) -> Option<&str> {
181 let marker = "const RAW_RUNTIME_STATE =";
182 let marker_index = content.find(marker)?;
183 let after_marker = &content[marker_index + marker.len()..];
184 let open_index = after_marker.find('{')?;
185 let json_start = marker_index + marker.len() + open_index;
186
187 let mut depth = 0usize;
188 let mut in_string = false;
189 let mut escaped = false;
190
191 for (offset, ch) in content[json_start..].char_indices() {
192 if in_string {
193 if escaped {
194 escaped = false;
195 } else if ch == '\\' {
196 escaped = true;
197 } else if ch == '"' {
198 in_string = false;
199 }
200 continue;
201 }
202
203 match ch {
204 '"' => in_string = true,
205 '{' => depth += 1,
206 '}' => {
207 depth = depth.saturating_sub(1);
208 if depth == 0 {
209 let end = json_start + offset + ch.len_utf8();
210 return Some(&content[json_start..end]);
211 }
212 }
213 _ => {}
214 }
215 }
216
217 None
218}