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