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::{Map as JsonMap, 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;
17pub struct VcpkgLockParser;
18
19impl PackageParser for VcpkgManifestParser {
20    const PACKAGE_TYPE: PackageType = PackageType::Vcpkg;
21
22    fn is_match(path: &Path) -> bool {
23        path.file_name().and_then(|name| name.to_str()) == Some("vcpkg.json")
24    }
25
26    fn extract_packages(path: &Path) -> Vec<PackageData> {
27        let content = match crate::parsers::utils::read_file_to_string(path, None) {
28            Ok(content) => content,
29            Err(e) => {
30                warn!("Failed to read vcpkg.json at {:?}: {}", path, e);
31                return vec![default_package_data()];
32            }
33        };
34
35        let json: Value = match serde_json::from_str(&content) {
36            Ok(json) => json,
37            Err(e) => {
38                warn!("Failed to parse vcpkg.json at {:?}: {}", path, e);
39                return vec![default_package_data()];
40            }
41        };
42
43        vec![parse_vcpkg_manifest(path, &json)]
44    }
45
46    fn metadata() -> Vec<super::metadata::ParserMetadata> {
47        vec![super::metadata::ParserMetadata {
48            description: "vcpkg manifest file",
49            file_patterns: &["**/vcpkg.json"],
50            package_type: "vcpkg",
51            primary_language: "",
52            documentation_url: Some("https://learn.microsoft.com/en-us/vcpkg/reference/vcpkg-json"),
53        }]
54    }
55}
56
57impl PackageParser for VcpkgLockParser {
58    const PACKAGE_TYPE: PackageType = PackageType::Vcpkg;
59
60    fn is_match(path: &Path) -> bool {
61        path.file_name().and_then(|name| name.to_str()) == Some("vcpkg-lock.json")
62    }
63
64    fn extract_packages(path: &Path) -> Vec<PackageData> {
65        let content = match crate::parsers::utils::read_file_to_string(path, None) {
66            Ok(content) => content,
67            Err(e) => {
68                warn!("Failed to read vcpkg-lock.json at {:?}: {}", path, e);
69                return vec![default_lock_package_data()];
70            }
71        };
72
73        let json: Value = match serde_json::from_str(&content) {
74            Ok(json) => json,
75            Err(e) => {
76                warn!("Failed to parse vcpkg-lock.json at {:?}: {}", path, e);
77                return vec![default_lock_package_data()];
78            }
79        };
80
81        vec![parse_vcpkg_lock(&json)]
82    }
83
84    fn metadata() -> Vec<super::metadata::ParserMetadata> {
85        vec![super::metadata::ParserMetadata {
86            description: "vcpkg registry lockfile",
87            file_patterns: &["**/vcpkg-lock.json"],
88            package_type: "vcpkg",
89            primary_language: "C++",
90            documentation_url: Some("https://github.com/microsoft/vcpkg-tool"),
91        }]
92    }
93}
94
95fn default_package_data() -> PackageData {
96    PackageData {
97        package_type: Some(PackageType::Vcpkg),
98        datasource_id: Some(DatasourceId::VcpkgJson),
99        ..Default::default()
100    }
101}
102
103fn default_lock_package_data() -> PackageData {
104    PackageData {
105        package_type: Some(PackageType::Vcpkg),
106        datasource_id: Some(DatasourceId::VcpkgLockJson),
107        is_private: true,
108        ..Default::default()
109    }
110}
111
112fn parse_vcpkg_manifest(path: &Path, json: &Value) -> PackageData {
113    let name = get_non_empty_string(json, "name").map(truncate_field);
114    let version = manifest_version(json).map(truncate_field);
115    let description = get_string_or_array(json, "description").map(truncate_field);
116    let homepage_url = get_non_empty_string(json, "homepage").map(truncate_field);
117    let extracted_license_statement = get_string_or_array(json, "license").map(truncate_field);
118    let parties = extract_maintainers(json);
119    let dependencies = extract_dependencies(json);
120    let extra_data = build_extra_data(path, json);
121
122    PackageData {
123        package_type: Some(PackageType::Vcpkg),
124        namespace: None,
125        name: name.clone(),
126        version: version.clone(),
127        primary_language: Some("C++".to_string()),
128        description,
129        parties,
130        homepage_url,
131        extracted_license_statement,
132        is_private: name.is_none(),
133        dependencies,
134        extra_data,
135        datasource_id: Some(DatasourceId::VcpkgJson),
136        purl: name
137            .as_deref()
138            .and_then(|name| build_vcpkg_purl(name, version.as_deref()))
139            .map(truncate_field),
140        ..default_package_data()
141    }
142}
143
144fn parse_vcpkg_lock(json: &Value) -> PackageData {
145    PackageData {
146        primary_language: Some("C++".to_string()),
147        extra_data: build_lock_extra_data(json),
148        ..default_lock_package_data()
149    }
150}
151
152fn build_lock_extra_data(json: &Value) -> Option<HashMap<String, Value>> {
153    let registry_locks = extract_lock_registry_entries(json);
154    if registry_locks.is_empty() {
155        return None;
156    }
157
158    let mut extra = HashMap::new();
159    extra.insert("registry_locks".to_string(), Value::Array(registry_locks));
160    Some(extra)
161}
162
163fn extract_lock_registry_entries(json: &Value) -> Vec<Value> {
164    let Some(registries) = json.as_object() else {
165        return Vec::new();
166    };
167
168    let registry_limit = capped_iteration_limit(registries.len(), "vcpkg lock registries");
169    registries
170        .iter()
171        .take(registry_limit)
172        .filter_map(|(location, references)| {
173            let references = references.as_object()?;
174            let reference_limit = capped_iteration_limit(references.len(), "vcpkg lock references");
175            let mut normalized_references = JsonMap::new();
176            for (reference, revision) in references.iter().take(reference_limit) {
177                let Some(revision) = revision
178                    .as_str()
179                    .map(str::trim)
180                    .filter(|revision| !revision.is_empty())
181                else {
182                    continue;
183                };
184
185                normalized_references.insert(
186                    truncate_field(reference.to_string()),
187                    Value::String(truncate_field(revision.to_string())),
188                );
189            }
190
191            if normalized_references.is_empty() {
192                return None;
193            }
194
195            let mut entry = JsonMap::new();
196            entry.insert(
197                "location".to_string(),
198                Value::String(truncate_field(location.to_string())),
199            );
200            entry.insert(
201                "references".to_string(),
202                Value::Object(normalized_references),
203            );
204            Some(Value::Object(entry))
205        })
206        .collect()
207}
208
209fn manifest_version(json: &Value) -> Option<String> {
210    let version = [
211        "version",
212        "version-semver",
213        "version-date",
214        "version-string",
215    ]
216    .into_iter()
217    .find_map(|field| get_non_empty_string(json, field));
218
219    match (version, json.get("port-version").and_then(Value::as_i64)) {
220        (Some(version), Some(port_version)) if port_version > 0 => {
221            Some(format!("{}#{}", version, port_version))
222        }
223        (version, _) => version,
224    }
225}
226
227fn extract_maintainers(json: &Value) -> Vec<Party> {
228    let Some(value) = json.get("maintainers") else {
229        return Vec::new();
230    };
231
232    let maintainers: Vec<String> = match value {
233        Value::String(s) => vec![s.clone()],
234        Value::Array(values) => {
235            let limit = capped_iteration_limit(values.len(), "vcpkg maintainers");
236            values
237                .iter()
238                .take(limit)
239                .filter_map(Value::as_str)
240                .map(ToOwned::to_owned)
241                .collect()
242        }
243        _ => Vec::new(),
244    };
245
246    maintainers
247        .into_iter()
248        .map(|entry| {
249            let (name, email) = split_name_email(&entry);
250            Party {
251                r#type: Some(PartyType::Person),
252                role: Some("maintainer".to_string()),
253                name: name.map(truncate_field),
254                email: email.map(truncate_field),
255                url: None,
256                organization: None,
257                organization_url: None,
258                timezone: None,
259            }
260        })
261        .collect()
262}
263
264fn extract_dependencies(json: &Value) -> Vec<Dependency> {
265    let mut dependencies: Vec<Dependency> = json
266        .get("dependencies")
267        .and_then(Value::as_array)
268        .map(|deps| {
269            let limit = capped_iteration_limit(deps.len(), "vcpkg dependencies");
270            deps.iter()
271                .take(limit)
272                .filter_map(parse_dependency_entry)
273                .collect()
274        })
275        .unwrap_or_default();
276
277    if let Some(features) = json.get("features").and_then(Value::as_object) {
278        let features_limit = capped_iteration_limit(features.len(), "vcpkg features");
279        for (feature_name, feature_value) in features.iter().take(features_limit) {
280            let Some(feature_dependencies) =
281                feature_value.get("dependencies").and_then(Value::as_array)
282            else {
283                continue;
284            };
285
286            let feature_deps_limit =
287                capped_iteration_limit(feature_dependencies.len(), "vcpkg feature dependencies");
288            for dependency in feature_dependencies
289                .iter()
290                .take(feature_deps_limit)
291                .filter_map(parse_dependency_entry)
292                .map(|mut dependency| {
293                    let mut extra_data = dependency.extra_data.take().unwrap_or_default();
294                    extra_data.insert(
295                        "feature".to_string(),
296                        Value::String(feature_name.to_string()),
297                    );
298                    dependency.extra_data = Some(extra_data);
299                    dependency
300                })
301            {
302                dependencies.push(dependency);
303            }
304        }
305    }
306
307    dependencies
308}
309
310fn parse_dependency_entry(value: &Value) -> Option<Dependency> {
311    match value {
312        Value::String(name) => Some(Dependency {
313            purl: build_vcpkg_purl(name, None).map(truncate_field),
314            extracted_requirement: Some(truncate_field(name.clone())),
315            scope: Some("dependencies".to_string()),
316            is_runtime: Some(true),
317            is_optional: Some(false),
318            is_pinned: Some(false),
319            is_direct: Some(true),
320            resolved_package: None,
321            extra_data: None,
322        }),
323        Value::Object(obj) => {
324            let name = obj.get("name").and_then(Value::as_str)?.trim();
325            if name.is_empty() {
326                return None;
327            }
328
329            let extracted_requirement = obj
330                .get("version>=")
331                .and_then(Value::as_str)
332                .map(|v| truncate_field(v.to_owned()))
333                .or_else(|| Some(truncate_field(name.to_string())));
334
335            let host = obj.get("host").and_then(Value::as_bool).unwrap_or(false);
336            let mut extra = HashMap::new();
337            for field in [
338                "version>=",
339                "features",
340                "default-features",
341                "host",
342                "platform",
343            ] {
344                if let Some(field_value) = obj.get(field) {
345                    extra.insert(field.to_string(), field_value.clone());
346                }
347            }
348
349            Some(Dependency {
350                purl: build_vcpkg_purl(name, None).map(truncate_field),
351                extracted_requirement,
352                scope: Some("dependencies".to_string()),
353                is_runtime: Some(!host),
354                is_optional: Some(false),
355                is_pinned: Some(false),
356                is_direct: Some(true),
357                resolved_package: None,
358                extra_data: (!extra.is_empty()).then_some(extra),
359            })
360        }
361        _ => None,
362    }
363}
364
365fn build_extra_data(path: &Path, json: &Value) -> Option<HashMap<String, Value>> {
366    let mut extra = HashMap::new();
367    for field in [
368        "builtin-baseline",
369        "overrides",
370        "supports",
371        "default-features",
372        "features",
373        "configuration",
374        "vcpkg-configuration",
375        "documentation",
376    ] {
377        if let Some(value) = json.get(field) {
378            extra.insert(field.to_string(), value.clone());
379        }
380    }
381
382    if !extra.contains_key("configuration")
383        && !extra.contains_key("vcpkg-configuration")
384        && let Some(config) = read_sibling_configuration(path)
385    {
386        extra.insert("configuration".to_string(), config);
387    }
388
389    (!extra.is_empty()).then_some(extra)
390}
391
392fn read_sibling_configuration(path: &Path) -> Option<Value> {
393    let sibling_path = path.with_file_name("vcpkg-configuration.json");
394    let content = crate::parsers::utils::read_file_to_string(&sibling_path, None).ok()?;
395    match serde_json::from_str(&content) {
396        Ok(value) => Some(value),
397        Err(e) => {
398            warn!(
399                "Failed to parse sibling vcpkg-configuration.json at {:?}: {}",
400                sibling_path, e
401            );
402            None
403        }
404    }
405}
406
407fn get_non_empty_string(json: &Value, field: &str) -> Option<String> {
408    json.get(field)
409        .and_then(Value::as_str)
410        .map(str::trim)
411        .filter(|value| !value.is_empty())
412        .map(|value| value.to_string())
413}
414
415fn get_string_or_array(json: &Value, field: &str) -> Option<String> {
416    match json.get(field) {
417        Some(Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
418        Some(Value::Array(values)) => {
419            let collected: Vec<_> = values
420                .iter()
421                .filter_map(Value::as_str)
422                .map(str::trim)
423                .filter(|s| !s.is_empty())
424                .collect();
425            (!collected.is_empty()).then(|| collected.join("\n"))
426        }
427        _ => None,
428    }
429}
430
431fn build_vcpkg_purl(name: &str, version: Option<&str>) -> Option<String> {
432    let mut purl = PackageUrl::new("generic", name).ok()?;
433    purl.with_namespace("vcpkg").ok()?;
434    if let Some(version) = version {
435        purl.with_version(version).ok()?;
436    }
437    Some(purl.to_string())
438}