Skip to main content

provenant/parsers/nuget/
project_json.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::path::Path;
5
6use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
7use crate::parser_warn as warn;
8use crate::utils::text::strip_utf8_bom_str;
9
10use super::super::PackageParser;
11use super::super::utils::{CappedIterExt, read_file_to_string, truncate_field};
12use super::{
13    build_nuget_party, build_nuget_purl, build_nuget_urls, default_package_data,
14    insert_extra_string,
15};
16
17const NUGET_PROJECT_JSON_KEYS: &[&str] = &[
18    "dependencies",
19    "frameworks",
20    "runtimes",
21    "supports",
22    "imports",
23    "tools",
24    "commands",
25    "scripts",
26    "buildOptions",
27    "packOptions",
28    "publishOptions",
29    "compilationOptions",
30];
31
32pub struct ProjectJsonParser;
33
34impl PackageParser for ProjectJsonParser {
35    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
36
37    fn is_match(path: &Path) -> bool {
38        path.file_name()
39            .and_then(|name| name.to_str())
40            .is_some_and(|name| name == "project.json")
41    }
42
43    fn extract_packages(path: &Path) -> Vec<PackageData> {
44        let raw = match read_file_to_string(path, None) {
45            Ok(c) => c,
46            Err(e) => {
47                warn!("Failed to read project.json at {:?}: {}", path, e);
48                return vec![default_package_data(Some(DatasourceId::NugetProjectJson))];
49            }
50        };
51        // Visual Studio writes project.json with a UTF-8 BOM, which serde_json
52        // rejects ("expected value at line 1 column 1"); strip it before parsing.
53        let content = strip_utf8_bom_str(&raw);
54
55        if !looks_like_probable_nuget_project_json_text(content) {
56            return Vec::new();
57        }
58
59        let parsed: serde_json::Value = match serde_json::from_str(content) {
60            Ok(value) => value,
61            Err(e) => {
62                warn!("Failed to parse project.json at {:?}: {}", path, e);
63                return vec![default_package_data(Some(DatasourceId::NugetProjectJson))];
64            }
65        };
66
67        if !looks_like_probable_nuget_project_json_value(&parsed) {
68            return Vec::new();
69        }
70
71        vec![parse_project_json_manifest(&parsed)]
72    }
73
74    fn metadata() -> Vec<super::super::metadata::ParserMetadata> {
75        vec![super::super::metadata::ParserMetadata {
76            description: "Legacy .NET project.json manifest",
77            file_patterns: &["**/project.json"],
78            package_type: "nuget",
79            primary_language: "C#",
80            documentation_url: Some("https://learn.microsoft.com/en-us/nuget/archive/project-json"),
81        }]
82    }
83}
84
85pub struct ProjectLockJsonParser;
86
87impl PackageParser for ProjectLockJsonParser {
88    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
89
90    fn is_match(path: &Path) -> bool {
91        path.file_name()
92            .and_then(|name| name.to_str())
93            .is_some_and(|name| name == "project.lock.json")
94    }
95
96    fn extract_packages(path: &Path) -> Vec<PackageData> {
97        let raw = match read_file_to_string(path, None) {
98            Ok(c) => c,
99            Err(e) => {
100                warn!("Failed to read project.lock.json at {:?}: {}", path, e);
101                return vec![default_package_data(Some(
102                    DatasourceId::NugetProjectLockJson,
103                ))];
104            }
105        };
106        // Strip a leading UTF-8 BOM (written by Visual Studio tooling) so serde_json
107        // does not fail on the first byte.
108        let content = strip_utf8_bom_str(&raw);
109
110        let parsed: serde_json::Value = match serde_json::from_str(content) {
111            Ok(value) => value,
112            Err(e) => {
113                warn!("Failed to parse project.lock.json at {:?}: {}", path, e);
114                return vec![default_package_data(Some(
115                    DatasourceId::NugetProjectLockJson,
116                ))];
117            }
118        };
119
120        vec![parse_project_lock_manifest(&parsed)]
121    }
122
123    fn metadata() -> Vec<super::super::metadata::ParserMetadata> {
124        vec![super::super::metadata::ParserMetadata {
125            description: ".NET project.lock.json lockfile",
126            file_patterns: &["**/project.lock.json"],
127            package_type: "nuget",
128            primary_language: "C#",
129            documentation_url: Some("https://learn.microsoft.com/en-us/nuget/archive/project-json"),
130        }]
131    }
132}
133
134fn parse_project_json_manifest(parsed: &serde_json::Value) -> PackageData {
135    let name = parsed
136        .get("name")
137        .and_then(|value| value.as_str())
138        .map(|value| value.to_string());
139    let version = parsed
140        .get("version")
141        .and_then(|value| value.as_str())
142        .map(|value| value.to_string());
143    let description = parsed
144        .get("description")
145        .and_then(|value| value.as_str())
146        .map(|value| value.to_string());
147    let homepage_url = parsed
148        .get("projectUrl")
149        .and_then(|value| value.as_str())
150        .map(|value| value.to_string());
151    let extracted_license_statement = parsed
152        .get("license")
153        .or_else(|| parsed.get("licenseUrl"))
154        .and_then(|value| value.as_str())
155        .map(|value| value.to_string());
156
157    let mut parties = Vec::new();
158    if let Some(authors) = parsed.get("authors") {
159        let author_name = if let Some(value) = authors.as_str() {
160            Some(value.to_string())
161        } else {
162            authors.as_array().map(|entries| {
163                entries
164                    .iter()
165                    .filter_map(|entry| entry.as_str())
166                    .collect::<Vec<_>>()
167                    .join(", ")
168            })
169        };
170
171        if let Some(author_name) = author_name.filter(|value| !value.is_empty()) {
172            parties.push(build_nuget_party("author", author_name));
173        }
174    }
175
176    let mut dependencies = Vec::new();
177
178    if let Some(root_dependencies) = parsed
179        .get("dependencies")
180        .and_then(|value| value.as_object())
181    {
182        for (dependency_name, dependency_spec) in root_dependencies
183            .iter()
184            .capped("project.json root dependencies")
185        {
186            if let Some(dependency) =
187                parse_project_json_dependency(dependency_name, dependency_spec, None)
188            {
189                dependencies.push(dependency);
190            }
191        }
192    }
193
194    if let Some(frameworks) = parsed.get("frameworks").and_then(|value| value.as_object()) {
195        for (framework, framework_value) in frameworks.iter().capped("project.json frameworks") {
196            let Some(framework_dependencies) = framework_value
197                .get("dependencies")
198                .and_then(|value| value.as_object())
199            else {
200                continue;
201            };
202
203            for (dependency_name, dependency_spec) in framework_dependencies
204                .iter()
205                .capped("project.json framework dependencies")
206            {
207                if let Some(dependency) = parse_project_json_dependency(
208                    dependency_name,
209                    dependency_spec,
210                    Some(framework.clone()),
211                ) {
212                    dependencies.push(dependency);
213                }
214            }
215        }
216    }
217
218    let (repository_homepage_url, repository_download_url, api_data_url) =
219        build_nuget_urls(name.as_deref(), version.as_deref());
220
221    PackageData {
222        datasource_id: Some(DatasourceId::NugetProjectJson),
223        package_type: Some(PackageType::Nuget),
224        name: name.clone().map(truncate_field),
225        version: version.clone().map(truncate_field),
226        purl: build_nuget_purl(name.as_deref(), version.as_deref()),
227        description: description.map(truncate_field),
228        homepage_url: homepage_url.map(truncate_field),
229        parties,
230        dependencies,
231        extracted_license_statement: extracted_license_statement.map(truncate_field),
232        repository_homepage_url,
233        repository_download_url,
234        api_data_url,
235        ..default_package_data(Some(DatasourceId::NugetProjectJson))
236    }
237}
238
239fn looks_like_probable_nuget_project_json_text(content: &str) -> bool {
240    NUGET_PROJECT_JSON_KEYS
241        .iter()
242        .any(|key| content.contains(&format!("\"{key}\"")))
243}
244
245fn looks_like_probable_nuget_project_json_value(parsed: &serde_json::Value) -> bool {
246    let Some(object) = parsed.as_object() else {
247        return false;
248    };
249
250    NUGET_PROJECT_JSON_KEYS
251        .iter()
252        .any(|key| object.contains_key(*key))
253}
254
255fn parse_project_json_dependency(
256    dependency_name: &str,
257    dependency_spec: &serde_json::Value,
258    scope: Option<String>,
259) -> Option<Dependency> {
260    let mut extra_data = serde_json::Map::new();
261
262    let requirement = match dependency_spec {
263        serde_json::Value::String(version) => Some(version.clone()),
264        serde_json::Value::Object(object) => {
265            let requirement = object
266                .get("version")
267                .and_then(|value| value.as_str())
268                .map(|value| value.to_string());
269            insert_extra_string(
270                &mut extra_data,
271                "include",
272                object
273                    .get("include")
274                    .and_then(|value| value.as_str())
275                    .map(|value| value.to_string()),
276            );
277            insert_extra_string(
278                &mut extra_data,
279                "exclude",
280                object
281                    .get("exclude")
282                    .and_then(|value| value.as_str())
283                    .map(|value| value.to_string()),
284            );
285            insert_extra_string(
286                &mut extra_data,
287                "type",
288                object
289                    .get("type")
290                    .and_then(|value| value.as_str())
291                    .map(|value| value.to_string()),
292            );
293            requirement
294        }
295        _ => return None,
296    };
297
298    Some(Dependency {
299        purl: build_nuget_purl(Some(dependency_name), None),
300        extracted_requirement: requirement,
301        scope,
302        is_runtime: Some(true),
303        is_optional: Some(false),
304        is_pinned: Some(false),
305        is_direct: Some(true),
306        resolved_package: None,
307        extra_data: if extra_data.is_empty() {
308            None
309        } else {
310            Some(extra_data.into_iter().collect())
311        },
312    })
313}
314
315fn parse_project_lock_manifest(parsed: &serde_json::Value) -> PackageData {
316    let mut dependencies = Vec::new();
317
318    if let Some(groups) = parsed
319        .get("projectFileDependencyGroups")
320        .and_then(|value| value.as_object())
321    {
322        for (framework, entries) in groups.iter().capped("project.lock dependency groups") {
323            let Some(entries) = entries.as_array() else {
324                continue;
325            };
326
327            for entry in entries
328                .iter()
329                .capped("project.lock group entries")
330                .filter_map(|value| value.as_str())
331            {
332                if let Some(dependency) = parse_project_lock_dependency(
333                    entry,
334                    (!framework.is_empty()).then(|| framework.clone()),
335                ) {
336                    dependencies.push(dependency);
337                }
338            }
339        }
340    }
341
342    PackageData {
343        datasource_id: Some(DatasourceId::NugetProjectLockJson),
344        package_type: Some(PackageType::Nuget),
345        dependencies,
346        ..default_package_data(Some(DatasourceId::NugetProjectLockJson))
347    }
348}
349
350fn parse_project_lock_dependency(entry: &str, scope: Option<String>) -> Option<Dependency> {
351    let trimmed = entry.trim();
352    if trimmed.is_empty() {
353        return None;
354    }
355
356    let mut parts = trimmed.split_whitespace();
357    let name = parts.next()?;
358    let requirement = parts.collect::<Vec<_>>().join(" ");
359
360    Some(Dependency {
361        purl: build_nuget_purl(Some(name), None),
362        extracted_requirement: (!requirement.is_empty()).then_some(requirement),
363        scope,
364        is_runtime: Some(true),
365        is_optional: Some(false),
366        is_pinned: Some(false),
367        is_direct: Some(true),
368        resolved_package: None,
369        extra_data: None,
370    })
371}