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