Skip to main content

provenant/parsers/
chef.rs

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