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