Skip to main content

provenant/parsers/
chef.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Chef cookbook metadata files (JSON and Ruby).
7//!
8//! Extracts package metadata, dependencies, and maintainer information from
9//! Chef cookbook metadata files used by the Chef configuration management tool.
10//!
11//! # Supported Formats
12//! - metadata.json (Chef cookbook metadata in JSON format)
13//! - metadata.rb (Chef cookbook metadata in Ruby DSL format)
14//!
15//! # Key Features
16//! - Maintainer party extraction from maintainer/maintainer_email fields
17//! - Dependency extraction from both `dependencies` and `depends` fields (merged)
18//! - URL construction for Chef Supermarket (download, homepage, API)
19//! - dist-info guard to prevent false positives with Python wheel metadata.json
20//!
21//! # Implementation Notes
22//! - JSON parser uses serde_json for JSON parsing
23//! - Ruby parser uses line-based token extraction (not a full Ruby parser)
24//! - Description from `description` or fallback to `long_description`
25//! - Graceful error handling: logs warnings and returns default on parse failure
26//! - IO.read(...) expressions in Ruby files are skipped (cannot evaluate Ruby code)
27
28use 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
80/// Chef metadata.json parser for Chef cookbook manifests.
81///
82/// Extracts metadata from Chef cookbook metadata.json files, including
83/// dependencies from both `dependencies` and `depends` fields.
84pub 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            // Check parent directory doesn't end with "dist-info"
102            // to prevent false positives with Python wheel metadata.json files
103            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    // Try description first, then long_description
238    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
250/// Chef metadata.rb parser for Chef cookbook manifests in Ruby DSL format.
251///
252/// Uses line-based token extraction to parse Ruby DSL without executing Ruby code.
253pub 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}