1use std::collections::HashMap;
29use std::fs::{self, File};
30use std::io::{BufRead, BufReader};
31use std::path::Path;
32use std::sync::LazyLock;
33
34use crate::parser_warn as warn;
35use packageurl::PackageUrl;
36use regex::Regex;
37use serde_json::Value;
38
39use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
40
41use super::PackageParser;
42use super::metadata::ParserMetadata;
43use super::utils::{
44 CappedIterExt, MAX_MANIFEST_SIZE, capped_iteration_limit, read_file_to_string, truncate_field,
45};
46
47const FIELD_NAME: &str = "name";
48const FIELD_VERSION: &str = "version";
49const FIELD_DESCRIPTION: &str = "description";
50const FIELD_LONG_DESCRIPTION: &str = "long_description";
51const FIELD_LICENSE: &str = "license";
52const FIELD_MAINTAINER: &str = "maintainer";
53const FIELD_MAINTAINER_EMAIL: &str = "maintainer_email";
54const FIELD_SOURCE_URL: &str = "source_url";
55const FIELD_ISSUES_URL: &str = "issues_url";
56const FIELD_DEPENDENCIES: &str = "dependencies";
57const FIELD_DEPENDS: &str = "depends";
58
59static RE_FIELD: LazyLock<Regex> =
60 LazyLock::new(|| Regex::new(r#"^\s*(\w+)\s+['"](.+?)['"]"#).expect("valid regex"));
61static RE_DEPENDS: LazyLock<Regex> = LazyLock::new(|| {
62 Regex::new(r#"^\s*depends\s+['"](.+?)['"](?:\s*,\s*['"](.+?)['"])?"#).expect("valid regex")
63});
64static RE_IO_READ: LazyLock<Regex> =
65 LazyLock::new(|| Regex::new(r"IO\.read\(").expect("valid regex"));
66
67struct ChefPackageFields {
68 datasource_id: DatasourceId,
69 name: Option<String>,
70 version: Option<String>,
71 description: Option<String>,
72 extracted_license_statement: Option<String>,
73 maintainer_name: Option<String>,
74 maintainer_email: Option<String>,
75 code_view_url: Option<String>,
76 bug_tracking_url: Option<String>,
77 deps: HashMap<String, Option<String>>,
78}
79
80pub struct ChefMetadataJsonParser;
85
86impl PackageParser for ChefMetadataJsonParser {
87 const PACKAGE_TYPE: PackageType = PackageType::Chef;
88
89 fn metadata() -> Vec<ParserMetadata> {
90 vec![ParserMetadata {
91 description: "Chef cookbook metadata",
92 file_patterns: &["**/metadata.json", "**/metadata.rb"],
93 package_type: "chef",
94 primary_language: "Ruby",
95 documentation_url: Some("https://docs.chef.io/config_rb_metadata/"),
96 }]
97 }
98
99 fn is_match(path: &Path) -> bool {
100 if path.file_name().is_some_and(|name| name == "metadata.json") {
101 if let Some(parent) = path.parent()
104 && let Some(parent_name) = parent.file_name().and_then(|n| n.to_str())
105 {
106 return !parent_name.ends_with("dist-info");
107 }
108 return true;
109 }
110 false
111 }
112
113 fn extract_packages(path: &Path) -> Vec<PackageData> {
114 let json_content = match read_json_file(path) {
115 Ok(content) => content,
116 Err(e) => {
117 warn!("Failed to read metadata.json at {:?}: {}", path, e);
118 return vec![default_package_data(DatasourceId::ChefCookbookMetadataJson)];
119 }
120 };
121
122 let name = json_content
123 .get(FIELD_NAME)
124 .and_then(|v| v.as_str())
125 .map(|s| s.trim().to_string())
126 .filter(|s| !s.is_empty())
127 .map(truncate_field);
128
129 let version = json_content
130 .get(FIELD_VERSION)
131 .and_then(|v| v.as_str())
132 .map(|s| s.trim().to_string())
133 .filter(|s| !s.is_empty())
134 .map(truncate_field);
135
136 let description = extract_description(&json_content).map(truncate_field);
137
138 let extracted_license_statement = json_content
139 .get(FIELD_LICENSE)
140 .and_then(|v| v.as_str())
141 .map(|s| s.trim().to_string())
142 .filter(|s| !s.is_empty())
143 .map(truncate_field);
144
145 let maintainer_name = json_content
146 .get(FIELD_MAINTAINER)
147 .and_then(|v| v.as_str())
148 .map(|s| s.trim().to_string())
149 .filter(|s| !s.is_empty())
150 .map(truncate_field);
151
152 let maintainer_email = json_content
153 .get(FIELD_MAINTAINER_EMAIL)
154 .and_then(|v| v.as_str())
155 .map(|s| s.trim().to_string())
156 .filter(|s| !s.is_empty())
157 .map(truncate_field);
158
159 let code_view_url = json_content
160 .get(FIELD_SOURCE_URL)
161 .and_then(|v| v.as_str())
162 .map(|s| s.trim().to_string())
163 .filter(|s| !s.is_empty())
164 .map(truncate_field);
165
166 let bug_tracking_url = json_content
167 .get(FIELD_ISSUES_URL)
168 .and_then(|v| v.as_str())
169 .map(|s| s.trim().to_string())
170 .filter(|s| !s.is_empty())
171 .map(truncate_field);
172
173 let mut deps: HashMap<String, Option<String>> = HashMap::new();
174
175 if let Some(deps_obj) = json_content
176 .get(FIELD_DEPENDENCIES)
177 .and_then(|v| v.as_object())
178 {
179 let limit = capped_iteration_limit(deps_obj.len(), "metadata.json dependencies");
180 for (dep_name, dep_version) in deps_obj.iter().take(limit) {
181 let version_constraint = dep_version
182 .as_str()
183 .map(|s| s.trim().to_string())
184 .filter(|s| !s.is_empty())
185 .map(truncate_field);
186 deps.insert(
187 truncate_field(dep_name.trim().to_string()),
188 version_constraint,
189 );
190 }
191 }
192
193 if let Some(depends_obj) = json_content.get(FIELD_DEPENDS).and_then(|v| v.as_object()) {
194 let limit = capped_iteration_limit(depends_obj.len(), "metadata.json depends");
195 for (dep_name, dep_version) in depends_obj.iter().take(limit) {
196 let version_constraint = dep_version
197 .as_str()
198 .map(|s| s.trim().to_string())
199 .filter(|s| !s.is_empty())
200 .map(truncate_field);
201 deps.insert(
202 truncate_field(dep_name.trim().to_string()),
203 version_constraint,
204 );
205 }
206 }
207
208 vec![build_package(ChefPackageFields {
209 datasource_id: DatasourceId::ChefCookbookMetadataJson,
210 name,
211 version,
212 description,
213 extracted_license_statement,
214 maintainer_name,
215 maintainer_email,
216 code_view_url,
217 bug_tracking_url,
218 deps,
219 })]
220 }
221}
222
223fn read_json_file(path: &Path) -> Result<Value, String> {
224 let contents = read_file_to_string(path, None).map_err(|e| e.to_string())?;
225 serde_json::from_str(&contents).map_err(|e| format!("Failed to parse JSON: {}", e))
226}
227
228fn default_package_data(datasource_id: DatasourceId) -> PackageData {
229 PackageData {
230 package_type: Some(ChefMetadataJsonParser::PACKAGE_TYPE),
231 datasource_id: Some(datasource_id),
232 ..Default::default()
233 }
234}
235
236fn extract_description(json: &Value) -> Option<String> {
237 json.get(FIELD_DESCRIPTION)
239 .and_then(|v| v.as_str())
240 .map(|s| s.trim().to_string())
241 .filter(|s| !s.is_empty())
242 .or_else(|| {
243 json.get(FIELD_LONG_DESCRIPTION)
244 .and_then(|v| v.as_str())
245 .map(|s| s.trim().to_string())
246 .filter(|s| !s.is_empty())
247 })
248}
249
250pub struct ChefMetadataRbParser;
254
255impl PackageParser for ChefMetadataRbParser {
256 const PACKAGE_TYPE: PackageType = PackageType::Chef;
257
258 fn is_match(path: &Path) -> bool {
259 path.file_name().is_some_and(|name| name == "metadata.rb")
260 }
261
262 fn extract_packages(path: &Path) -> Vec<PackageData> {
263 if let Ok(metadata) = fs::metadata(path)
264 && metadata.len() > MAX_MANIFEST_SIZE
265 {
266 warn!(
267 "File {:?} is {} bytes, exceeding the {} byte limit",
268 path,
269 metadata.len(),
270 MAX_MANIFEST_SIZE
271 );
272 return vec![default_package_data(DatasourceId::ChefCookbookMetadataRb)];
273 }
274
275 let file = match File::open(path) {
276 Ok(f) => f,
277 Err(e) => {
278 warn!("Failed to open metadata.rb at {:?}: {}", path, e);
279 return vec![default_package_data(DatasourceId::ChefCookbookMetadataRb)];
280 }
281 };
282
283 let reader = BufReader::new(file);
284 let mut fields: HashMap<String, String> = HashMap::new();
285 let mut deps: HashMap<String, Option<String>> = HashMap::new();
286
287 for line in reader.lines().capped("metadata.rb lines") {
288 let line = match line {
289 Ok(l) => l,
290 Err(e) => {
291 warn!("Skipping non-UTF-8 line in {:?}: {}", path, e);
292 continue;
293 }
294 };
295
296 let trimmed = line.trim();
297
298 if trimmed.is_empty() || trimmed.starts_with('#') {
299 continue;
300 }
301
302 if RE_IO_READ.is_match(&line) {
303 continue;
304 }
305
306 if let Some(caps) = RE_DEPENDS.captures(&line) {
307 let dep_name = caps
308 .get(1)
309 .map(|m| m.as_str().to_string())
310 .unwrap_or_default();
311 let dep_version = caps.get(2).map(|m| m.as_str().to_string());
312 if !dep_name.is_empty() {
313 deps.insert(dep_name, dep_version);
314 }
315 continue;
316 }
317
318 if let Some(caps) = RE_FIELD.captures(&line) {
319 let key = caps
320 .get(1)
321 .map(|m| m.as_str().to_string())
322 .unwrap_or_default();
323 let value = caps
324 .get(2)
325 .map(|m| m.as_str().to_string())
326 .unwrap_or_default();
327
328 if !key.is_empty() && !value.is_empty() {
329 match key.as_str() {
330 "name" | "version" | "description" | "long_description" | "license"
331 | "maintainer" | "maintainer_email" | "source_url" | "issues_url" => {
332 fields.insert(key, value);
333 }
334 _ => {}
335 }
336 }
337 }
338 }
339
340 let name = fields
341 .get("name")
342 .map(|s| s.trim().to_string())
343 .filter(|s| !s.is_empty())
344 .map(truncate_field);
345
346 let version = fields
347 .get("version")
348 .map(|s| s.trim().to_string())
349 .filter(|s| !s.is_empty())
350 .map(truncate_field);
351
352 let description = fields
353 .get("description")
354 .map(|s| s.trim().to_string())
355 .filter(|s| !s.is_empty())
356 .map(truncate_field)
357 .or_else(|| {
358 fields
359 .get("long_description")
360 .map(|s| s.trim().to_string())
361 .filter(|s| !s.is_empty())
362 .map(truncate_field)
363 });
364
365 let extracted_license_statement = fields
366 .get("license")
367 .map(|s| s.trim().to_string())
368 .filter(|s| !s.is_empty())
369 .map(truncate_field);
370
371 let maintainer_name = fields
372 .get("maintainer")
373 .map(|s| s.trim().to_string())
374 .filter(|s| !s.is_empty())
375 .map(truncate_field);
376
377 let maintainer_email = fields
378 .get("maintainer_email")
379 .map(|s| s.trim().to_string())
380 .filter(|s| !s.is_empty())
381 .map(truncate_field);
382
383 let code_view_url = fields
384 .get("source_url")
385 .map(|s| s.trim().to_string())
386 .filter(|s| !s.is_empty())
387 .map(truncate_field);
388
389 let bug_tracking_url = fields
390 .get("issues_url")
391 .map(|s| s.trim().to_string())
392 .filter(|s| !s.is_empty())
393 .map(truncate_field);
394
395 vec![build_package(ChefPackageFields {
396 datasource_id: DatasourceId::ChefCookbookMetadataRb,
397 name,
398 version,
399 description,
400 extracted_license_statement,
401 maintainer_name,
402 maintainer_email,
403 code_view_url,
404 bug_tracking_url,
405 deps,
406 })]
407 }
408}
409
410fn build_package(fields: ChefPackageFields) -> PackageData {
411 let ChefPackageFields {
412 datasource_id,
413 name,
414 version,
415 description,
416 extracted_license_statement,
417 maintainer_name,
418 maintainer_email,
419 code_view_url,
420 bug_tracking_url,
421 deps,
422 } = fields;
423 let parties = if maintainer_name.is_some() || maintainer_email.is_some() {
424 vec![Party {
425 r#type: None,
426 role: Some(truncate_field("maintainer".to_string())),
427 name: maintainer_name.map(truncate_field),
428 email: maintainer_email.map(truncate_field),
429 url: None,
430 organization: None,
431 organization_url: None,
432 timezone: None,
433 }]
434 } else {
435 Vec::new()
436 };
437
438 let mut dependencies: Vec<Dependency> = deps
439 .into_iter()
440 .map(|(dep_name, version_constraint)| {
441 let purl = PackageUrl::new("chef", &dep_name)
442 .map(|p| truncate_field(p.to_string()))
443 .ok();
444 Dependency {
445 purl,
446 extracted_requirement: version_constraint.map(truncate_field),
447 scope: Some(truncate_field("dependencies".to_string())),
448 is_runtime: Some(true),
449 is_optional: Some(false),
450 is_pinned: None,
451 is_direct: None,
452 resolved_package: None,
453 extra_data: None,
454 }
455 })
456 .collect();
457
458 dependencies.sort_by(|a, b| {
459 let name_a = a.purl.as_deref().unwrap_or("");
460 let name_b = b.purl.as_deref().unwrap_or("");
461 name_a.cmp(name_b)
462 });
463
464 let (download_url, repository_download_url, repository_homepage_url, api_data_url) =
465 if let (Some(n), Some(v)) = (&name, &version) {
466 let download = truncate_field(format!(
467 "https://supermarket.chef.io/cookbooks/{}/versions/{}/download",
468 n, v
469 ));
470 let homepage = truncate_field(format!(
471 "https://supermarket.chef.io/cookbooks/{}/versions/{}/",
472 n, v
473 ));
474 let api = truncate_field(format!(
475 "https://supermarket.chef.io/api/v1/cookbooks/{}/versions/{}",
476 n, v
477 ));
478 (
479 Some(download.clone()),
480 Some(download),
481 Some(homepage),
482 Some(api),
483 )
484 } else {
485 (None, None, None, None)
486 };
487
488 let purl = match (name.as_deref(), version.as_deref()) {
489 (Some(name), Some(version)) => PackageUrl::new("chef", name)
490 .map(|mut p| {
491 let _ = p.with_version(version);
492 truncate_field(p.to_string())
493 })
494 .ok(),
495 _ => None,
496 };
497
498 PackageData {
499 package_type: Some(ChefMetadataJsonParser::PACKAGE_TYPE),
500 datasource_id: Some(datasource_id),
501 name,
502 version,
503 description,
504 extracted_license_statement,
505 parties,
506 code_view_url,
507 bug_tracking_url,
508 dependencies,
509 download_url,
510 repository_download_url,
511 repository_homepage_url,
512 api_data_url,
513 purl,
514 primary_language: Some(truncate_field("Ruby".to_string())),
515 ..Default::default()
516 }
517}