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::{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
78/// Chef metadata.json parser for Chef cookbook manifests.
79///
80/// Extracts metadata from Chef cookbook metadata.json files, including
81/// dependencies from both `dependencies` and `depends` fields.
82pub 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            // Check parent directory doesn't end with "dist-info"
100            // to prevent false positives with Python wheel metadata.json files
101            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    // Try description first, then long_description
234    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
246/// Chef metadata.rb parser for Chef cookbook manifests in Ruby DSL format.
247///
248/// Uses line-based token extraction to parse Ruby DSL without executing Ruby code.
249pub 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}