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::{MAX_ITERATION_COUNT, 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.iter().take(MAX_ITERATION_COUNT)
183        {
184            if let Some(dependency) =
185                parse_project_json_dependency(dependency_name, dependency_spec, None)
186            {
187                dependencies.push(dependency);
188            }
189        }
190    }
191
192    if let Some(frameworks) = parsed.get("frameworks").and_then(|value| value.as_object()) {
193        for (framework, framework_value) in frameworks.iter().take(MAX_ITERATION_COUNT) {
194            let Some(framework_dependencies) = framework_value
195                .get("dependencies")
196                .and_then(|value| value.as_object())
197            else {
198                continue;
199            };
200
201            for (dependency_name, dependency_spec) in
202                framework_dependencies.iter().take(MAX_ITERATION_COUNT)
203            {
204                if let Some(dependency) = parse_project_json_dependency(
205                    dependency_name,
206                    dependency_spec,
207                    Some(framework.clone()),
208                ) {
209                    dependencies.push(dependency);
210                }
211            }
212        }
213    }
214
215    let (repository_homepage_url, repository_download_url, api_data_url) =
216        build_nuget_urls(name.as_deref(), version.as_deref());
217
218    PackageData {
219        datasource_id: Some(DatasourceId::NugetProjectJson),
220        package_type: Some(PackageType::Nuget),
221        name: name.clone().map(truncate_field),
222        version: version.clone().map(truncate_field),
223        purl: build_nuget_purl(name.as_deref(), version.as_deref()),
224        description: description.map(truncate_field),
225        homepage_url: homepage_url.map(truncate_field),
226        parties,
227        dependencies,
228        extracted_license_statement: extracted_license_statement.map(truncate_field),
229        repository_homepage_url,
230        repository_download_url,
231        api_data_url,
232        ..default_package_data(Some(DatasourceId::NugetProjectJson))
233    }
234}
235
236fn looks_like_probable_nuget_project_json_text(content: &str) -> bool {
237    NUGET_PROJECT_JSON_KEYS
238        .iter()
239        .any(|key| content.contains(&format!("\"{key}\"")))
240}
241
242fn looks_like_probable_nuget_project_json_value(parsed: &serde_json::Value) -> bool {
243    let Some(object) = parsed.as_object() else {
244        return false;
245    };
246
247    NUGET_PROJECT_JSON_KEYS
248        .iter()
249        .any(|key| object.contains_key(*key))
250}
251
252fn parse_project_json_dependency(
253    dependency_name: &str,
254    dependency_spec: &serde_json::Value,
255    scope: Option<String>,
256) -> Option<Dependency> {
257    let mut extra_data = serde_json::Map::new();
258
259    let requirement = match dependency_spec {
260        serde_json::Value::String(version) => Some(version.clone()),
261        serde_json::Value::Object(object) => {
262            let requirement = object
263                .get("version")
264                .and_then(|value| value.as_str())
265                .map(|value| value.to_string());
266            insert_extra_string(
267                &mut extra_data,
268                "include",
269                object
270                    .get("include")
271                    .and_then(|value| value.as_str())
272                    .map(|value| value.to_string()),
273            );
274            insert_extra_string(
275                &mut extra_data,
276                "exclude",
277                object
278                    .get("exclude")
279                    .and_then(|value| value.as_str())
280                    .map(|value| value.to_string()),
281            );
282            insert_extra_string(
283                &mut extra_data,
284                "type",
285                object
286                    .get("type")
287                    .and_then(|value| value.as_str())
288                    .map(|value| value.to_string()),
289            );
290            requirement
291        }
292        _ => return None,
293    };
294
295    Some(Dependency {
296        purl: build_nuget_purl(Some(dependency_name), None),
297        extracted_requirement: requirement,
298        scope,
299        is_runtime: Some(true),
300        is_optional: Some(false),
301        is_pinned: Some(false),
302        is_direct: Some(true),
303        resolved_package: None,
304        extra_data: if extra_data.is_empty() {
305            None
306        } else {
307            Some(extra_data.into_iter().collect())
308        },
309    })
310}
311
312fn parse_project_lock_manifest(parsed: &serde_json::Value) -> PackageData {
313    let mut dependencies = Vec::new();
314
315    if let Some(groups) = parsed
316        .get("projectFileDependencyGroups")
317        .and_then(|value| value.as_object())
318    {
319        for (framework, entries) in groups.iter().take(MAX_ITERATION_COUNT) {
320            let Some(entries) = entries.as_array() else {
321                continue;
322            };
323
324            for entry in entries
325                .iter()
326                .take(MAX_ITERATION_COUNT)
327                .filter_map(|value| value.as_str())
328            {
329                if let Some(dependency) = parse_project_lock_dependency(
330                    entry,
331                    (!framework.is_empty()).then(|| framework.clone()),
332                ) {
333                    dependencies.push(dependency);
334                }
335            }
336        }
337    }
338
339    PackageData {
340        datasource_id: Some(DatasourceId::NugetProjectLockJson),
341        package_type: Some(PackageType::Nuget),
342        dependencies,
343        ..default_package_data(Some(DatasourceId::NugetProjectLockJson))
344    }
345}
346
347fn parse_project_lock_dependency(entry: &str, scope: Option<String>) -> Option<Dependency> {
348    let trimmed = entry.trim();
349    if trimmed.is_empty() {
350        return None;
351    }
352
353    let mut parts = trimmed.split_whitespace();
354    let name = parts.next()?;
355    let requirement = parts.collect::<Vec<_>>().join(" ");
356
357    Some(Dependency {
358        purl: build_nuget_purl(Some(name), None),
359        extracted_requirement: (!requirement.is_empty()).then_some(requirement),
360        scope,
361        is_runtime: Some(true),
362        is_optional: Some(false),
363        is_pinned: Some(false),
364        is_direct: Some(true),
365        resolved_package: None,
366        extra_data: None,
367    })
368}