Skip to main content

provenant/parsers/
vcpkg.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9use serde_json::Value;
10
11use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
12use crate::parsers::utils::{capped_iteration_limit, split_name_email, truncate_field};
13
14use super::PackageParser;
15
16pub struct VcpkgManifestParser;
17
18impl PackageParser for VcpkgManifestParser {
19    const PACKAGE_TYPE: PackageType = PackageType::Vcpkg;
20
21    fn is_match(path: &Path) -> bool {
22        path.file_name().and_then(|name| name.to_str()) == Some("vcpkg.json")
23    }
24
25    fn extract_packages(path: &Path) -> Vec<PackageData> {
26        let content = match crate::parsers::utils::read_file_to_string(path, None) {
27            Ok(content) => content,
28            Err(e) => {
29                warn!("Failed to read vcpkg.json at {:?}: {}", path, e);
30                return vec![default_package_data()];
31            }
32        };
33
34        let json: Value = match serde_json::from_str(&content) {
35            Ok(json) => json,
36            Err(e) => {
37                warn!("Failed to parse vcpkg.json at {:?}: {}", path, e);
38                return vec![default_package_data()];
39            }
40        };
41
42        vec![parse_vcpkg_manifest(path, &json)]
43    }
44
45    fn metadata() -> Vec<super::metadata::ParserMetadata> {
46        vec![super::metadata::ParserMetadata {
47            description: "vcpkg manifest file",
48            file_patterns: &["**/vcpkg.json"],
49            package_type: "vcpkg",
50            primary_language: "",
51            documentation_url: Some("https://learn.microsoft.com/en-us/vcpkg/reference/vcpkg-json"),
52        }]
53    }
54}
55
56fn default_package_data() -> PackageData {
57    PackageData {
58        package_type: Some(PackageType::Vcpkg),
59        datasource_id: Some(DatasourceId::VcpkgJson),
60        ..Default::default()
61    }
62}
63
64fn parse_vcpkg_manifest(path: &Path, json: &Value) -> PackageData {
65    let name = get_non_empty_string(json, "name").map(truncate_field);
66    let version = manifest_version(json).map(truncate_field);
67    let description = get_string_or_array(json, "description").map(truncate_field);
68    let homepage_url = get_non_empty_string(json, "homepage").map(truncate_field);
69    let extracted_license_statement = get_string_or_array(json, "license").map(truncate_field);
70    let parties = extract_maintainers(json);
71    let dependencies = extract_dependencies(json);
72    let extra_data = build_extra_data(path, json);
73
74    PackageData {
75        package_type: Some(PackageType::Vcpkg),
76        namespace: None,
77        name: name.clone(),
78        version: version.clone(),
79        primary_language: Some("C++".to_string()),
80        description,
81        parties,
82        homepage_url,
83        extracted_license_statement,
84        is_private: name.is_none(),
85        dependencies,
86        extra_data,
87        datasource_id: Some(DatasourceId::VcpkgJson),
88        purl: name
89            .as_deref()
90            .and_then(|name| build_vcpkg_purl(name, version.as_deref()))
91            .map(truncate_field),
92        ..default_package_data()
93    }
94}
95
96fn manifest_version(json: &Value) -> Option<String> {
97    let version = [
98        "version",
99        "version-semver",
100        "version-date",
101        "version-string",
102    ]
103    .into_iter()
104    .find_map(|field| get_non_empty_string(json, field));
105
106    match (version, json.get("port-version").and_then(Value::as_i64)) {
107        (Some(version), Some(port_version)) if port_version > 0 => {
108            Some(format!("{}#{}", version, port_version))
109        }
110        (version, _) => version,
111    }
112}
113
114fn extract_maintainers(json: &Value) -> Vec<Party> {
115    let Some(value) = json.get("maintainers") else {
116        return Vec::new();
117    };
118
119    let maintainers: Vec<String> = match value {
120        Value::String(s) => vec![s.clone()],
121        Value::Array(values) => {
122            let limit = capped_iteration_limit(values.len(), "vcpkg maintainers");
123            values
124                .iter()
125                .take(limit)
126                .filter_map(Value::as_str)
127                .map(ToOwned::to_owned)
128                .collect()
129        }
130        _ => Vec::new(),
131    };
132
133    maintainers
134        .into_iter()
135        .map(|entry| {
136            let (name, email) = split_name_email(&entry);
137            Party {
138                r#type: Some(PartyType::Person),
139                role: Some("maintainer".to_string()),
140                name: name.map(truncate_field),
141                email: email.map(truncate_field),
142                url: None,
143                organization: None,
144                organization_url: None,
145                timezone: None,
146            }
147        })
148        .collect()
149}
150
151fn extract_dependencies(json: &Value) -> Vec<Dependency> {
152    let mut dependencies: Vec<Dependency> = json
153        .get("dependencies")
154        .and_then(Value::as_array)
155        .map(|deps| {
156            let limit = capped_iteration_limit(deps.len(), "vcpkg dependencies");
157            deps.iter()
158                .take(limit)
159                .filter_map(parse_dependency_entry)
160                .collect()
161        })
162        .unwrap_or_default();
163
164    if let Some(features) = json.get("features").and_then(Value::as_object) {
165        let features_limit = capped_iteration_limit(features.len(), "vcpkg features");
166        for (feature_name, feature_value) in features.iter().take(features_limit) {
167            let Some(feature_dependencies) =
168                feature_value.get("dependencies").and_then(Value::as_array)
169            else {
170                continue;
171            };
172
173            let feature_deps_limit =
174                capped_iteration_limit(feature_dependencies.len(), "vcpkg feature dependencies");
175            for dependency in feature_dependencies
176                .iter()
177                .take(feature_deps_limit)
178                .filter_map(parse_dependency_entry)
179                .map(|mut dependency| {
180                    let mut extra_data = dependency.extra_data.take().unwrap_or_default();
181                    extra_data.insert(
182                        "feature".to_string(),
183                        Value::String(feature_name.to_string()),
184                    );
185                    dependency.extra_data = Some(extra_data);
186                    dependency
187                })
188            {
189                dependencies.push(dependency);
190            }
191        }
192    }
193
194    dependencies
195}
196
197fn parse_dependency_entry(value: &Value) -> Option<Dependency> {
198    match value {
199        Value::String(name) => Some(Dependency {
200            purl: build_vcpkg_purl(name, None).map(truncate_field),
201            extracted_requirement: Some(truncate_field(name.clone())),
202            scope: Some("dependencies".to_string()),
203            is_runtime: Some(true),
204            is_optional: Some(false),
205            is_pinned: Some(false),
206            is_direct: Some(true),
207            resolved_package: None,
208            extra_data: None,
209        }),
210        Value::Object(obj) => {
211            let name = obj.get("name").and_then(Value::as_str)?.trim();
212            if name.is_empty() {
213                return None;
214            }
215
216            let extracted_requirement = obj
217                .get("version>=")
218                .and_then(Value::as_str)
219                .map(|v| truncate_field(v.to_owned()))
220                .or_else(|| Some(truncate_field(name.to_string())));
221
222            let host = obj.get("host").and_then(Value::as_bool).unwrap_or(false);
223            let mut extra = HashMap::new();
224            for field in [
225                "version>=",
226                "features",
227                "default-features",
228                "host",
229                "platform",
230            ] {
231                if let Some(field_value) = obj.get(field) {
232                    extra.insert(field.to_string(), field_value.clone());
233                }
234            }
235
236            Some(Dependency {
237                purl: build_vcpkg_purl(name, None).map(truncate_field),
238                extracted_requirement,
239                scope: Some("dependencies".to_string()),
240                is_runtime: Some(!host),
241                is_optional: Some(false),
242                is_pinned: Some(false),
243                is_direct: Some(true),
244                resolved_package: None,
245                extra_data: (!extra.is_empty()).then_some(extra),
246            })
247        }
248        _ => None,
249    }
250}
251
252fn build_extra_data(path: &Path, json: &Value) -> Option<HashMap<String, Value>> {
253    let mut extra = HashMap::new();
254    for field in [
255        "builtin-baseline",
256        "overrides",
257        "supports",
258        "default-features",
259        "features",
260        "configuration",
261        "vcpkg-configuration",
262        "documentation",
263    ] {
264        if let Some(value) = json.get(field) {
265            extra.insert(field.to_string(), value.clone());
266        }
267    }
268
269    if !extra.contains_key("configuration")
270        && !extra.contains_key("vcpkg-configuration")
271        && let Some(config) = read_sibling_configuration(path)
272    {
273        extra.insert("configuration".to_string(), config);
274    }
275
276    (!extra.is_empty()).then_some(extra)
277}
278
279fn read_sibling_configuration(path: &Path) -> Option<Value> {
280    let sibling_path = path.with_file_name("vcpkg-configuration.json");
281    let content = crate::parsers::utils::read_file_to_string(&sibling_path, None).ok()?;
282    match serde_json::from_str(&content) {
283        Ok(value) => Some(value),
284        Err(e) => {
285            warn!(
286                "Failed to parse sibling vcpkg-configuration.json at {:?}: {}",
287                sibling_path, e
288            );
289            None
290        }
291    }
292}
293
294fn get_non_empty_string(json: &Value, field: &str) -> Option<String> {
295    json.get(field)
296        .and_then(Value::as_str)
297        .map(str::trim)
298        .filter(|value| !value.is_empty())
299        .map(|value| value.to_string())
300}
301
302fn get_string_or_array(json: &Value, field: &str) -> Option<String> {
303    match json.get(field) {
304        Some(Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
305        Some(Value::Array(values)) => {
306            let collected: Vec<_> = values
307                .iter()
308                .filter_map(Value::as_str)
309                .map(str::trim)
310                .filter(|s| !s.is_empty())
311                .collect();
312            (!collected.is_empty()).then(|| collected.join("\n"))
313        }
314        _ => None,
315    }
316}
317
318fn build_vcpkg_purl(name: &str, version: Option<&str>) -> Option<String> {
319    let mut purl = PackageUrl::new("generic", name).ok()?;
320    purl.with_namespace("vcpkg").ok()?;
321    if let Some(version) = version {
322        purl.with_version(version).ok()?;
323    }
324    Some(purl.to_string())
325}