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