Skip to main content

provenant/parsers/nuget/
deps_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};
11use super::{build_nuget_purl, build_nuget_urls, default_package_data};
12
13pub struct DotNetDepsJsonParser;
14
15impl PackageParser for DotNetDepsJsonParser {
16    const PACKAGE_TYPE: PackageType = PackageType::Nuget;
17
18    fn is_match(path: &Path) -> bool {
19        path.file_name()
20            .and_then(|name| name.to_str())
21            .is_some_and(|name| name.ends_with(".deps.json"))
22    }
23
24    fn extract_packages(path: &Path) -> Vec<PackageData> {
25        let content = match read_file_to_string(path, None) {
26            Ok(c) => c,
27            Err(e) => {
28                warn!("Failed to read .deps.json at {:?}: {}", path, e);
29                return vec![default_package_data(Some(DatasourceId::NugetDepsJson))];
30            }
31        };
32
33        let parsed: serde_json::Value = match serde_json::from_str(&content) {
34            Ok(value) => value,
35            Err(e) => {
36                warn!("Failed to parse .deps.json at {:?}: {}", path, e);
37                return vec![default_package_data(Some(DatasourceId::NugetDepsJson))];
38            }
39        };
40
41        vec![parse_dotnet_deps_json(&parsed, path)]
42    }
43
44    fn metadata() -> Vec<super::super::metadata::ParserMetadata> {
45        vec![super::super::metadata::ParserMetadata {
46            description: ".NET .deps.json runtime dependency graph",
47            file_patterns: &["**/*.deps.json"],
48            package_type: "nuget",
49            primary_language: "C#",
50            documentation_url: Some(
51                "https://learn.microsoft.com/en-us/dotnet/core/dependency-loading/default-probing",
52            ),
53        }]
54    }
55}
56
57fn parse_dotnet_deps_json(parsed: &serde_json::Value, path: &Path) -> PackageData {
58    let Some(libraries) = parsed.get("libraries").and_then(|value| value.as_object()) else {
59        return default_package_data(Some(DatasourceId::NugetDepsJson));
60    };
61
62    let Some((selected_target_name, selected_target)) = select_deps_target(parsed) else {
63        return default_package_data(Some(DatasourceId::NugetDepsJson));
64    };
65
66    let root_key = select_root_library_key(path, libraries, &selected_target);
67    let root_dependencies = root_key
68        .as_deref()
69        .and_then(|root_key| selected_target.get(root_key))
70        .and_then(|value| value.get("dependencies"))
71        .and_then(|value| value.as_object())
72        .cloned()
73        .unwrap_or_default();
74
75    let mut dependencies = Vec::new();
76    let mut iteration_count: usize = 0;
77    for (library_key, target_entry) in selected_target.iter().take(MAX_ITERATION_COUNT) {
78        iteration_count += 1;
79        if iteration_count > MAX_ITERATION_COUNT {
80            warn!(
81                "Iteration limit exceeded in .deps.json at {:?}; stopping at {} dependencies",
82                path, MAX_ITERATION_COUNT
83            );
84            break;
85        }
86        if root_key.as_deref() == Some(library_key.as_str()) {
87            continue;
88        }
89
90        let Some((name, version)) = split_library_key(library_key) else {
91            continue;
92        };
93        let Some(library_metadata) = libraries
94            .get(library_key)
95            .and_then(|value| value.as_object())
96        else {
97            continue;
98        };
99
100        let mut extra_data = serde_json::Map::new();
101        extra_data.insert(
102            "target_name".to_string(),
103            serde_json::Value::String(selected_target_name.clone()),
104        );
105
106        for field in [
107            "type",
108            "sha512",
109            "path",
110            "hashPath",
111            "runtimeStoreManifestName",
112        ] {
113            if let Some(value) = library_metadata.get(field) {
114                extra_data.insert(field.to_string(), value.clone());
115            }
116        }
117
118        if let Some(value) = library_metadata.get("serviceable") {
119            extra_data.insert("serviceable".to_string(), value.clone());
120        }
121
122        if let Some(object) = target_entry.as_object() {
123            for field in ["runtime", "native", "runtimeTargets", "resources"] {
124                if let Some(value) = object.get(field) {
125                    extra_data.insert(field.to_string(), value.clone());
126                }
127            }
128            if let Some(value) = object.get("compileOnly") {
129                extra_data.insert("compileOnly".to_string(), value.clone());
130            }
131        }
132
133        let is_direct = if root_key.is_some() {
134            Some(root_dependencies.contains_key(name))
135        } else {
136            None
137        };
138
139        let compile_only = target_entry
140            .get("compileOnly")
141            .and_then(|value| value.as_bool())
142            .unwrap_or(false);
143
144        dependencies.push(Dependency {
145            purl: build_nuget_purl(Some(name), Some(version)),
146            extracted_requirement: Some(version.to_string()),
147            scope: Some(selected_target_name.clone()),
148            is_runtime: Some(!compile_only),
149            is_optional: Some(compile_only),
150            is_pinned: Some(true),
151            is_direct,
152            resolved_package: None,
153            extra_data: if extra_data.is_empty() {
154                None
155            } else {
156                Some(extra_data.into_iter().collect())
157            },
158        });
159    }
160
161    let mut package_data = if let Some(root_key) = root_key {
162        let (name, version) = split_library_key(&root_key).unwrap_or(("", ""));
163        let mut package = default_package_data(Some(DatasourceId::NugetDepsJson));
164        package.name = (!name.is_empty()).then(|| name.to_string());
165        package.version = (!version.is_empty()).then(|| version.to_string());
166        package.purl = build_nuget_purl(package.name.as_deref(), package.version.as_deref());
167        let (repository_homepage_url, repository_download_url, api_data_url) =
168            build_nuget_urls(package.name.as_deref(), package.version.as_deref());
169        package.repository_homepage_url = repository_homepage_url;
170        package.repository_download_url = repository_download_url;
171        package.api_data_url = api_data_url;
172        package
173    } else {
174        let mut package = default_package_data(Some(DatasourceId::NugetDepsJson));
175        let file_stem = path
176            .file_name()
177            .and_then(|name| name.to_str())
178            .and_then(|name| name.strip_suffix(".deps.json"))
179            .filter(|name| !name.trim().is_empty())
180            .map(|name| name.to_string());
181        package.name = file_stem.clone();
182        package.purl = build_nuget_purl(file_stem.as_deref(), None);
183        package
184    };
185
186    let mut extra_data = serde_json::Map::new();
187    if let Some(runtime_target) = parsed
188        .get("runtimeTarget")
189        .and_then(|value| value.as_object())
190    {
191        if let Some(name) = runtime_target.get("name").and_then(|value| value.as_str()) {
192            extra_data.insert(
193                "runtime_target_name".to_string(),
194                serde_json::Value::String(name.to_string()),
195            );
196            if let Some((framework, runtime_identifier)) = name.split_once('/') {
197                extra_data.insert(
198                    "target_framework".to_string(),
199                    serde_json::Value::String(framework.to_string()),
200                );
201                extra_data.insert(
202                    "runtime_identifier".to_string(),
203                    serde_json::Value::String(runtime_identifier.to_string()),
204                );
205            } else {
206                extra_data.insert(
207                    "target_framework".to_string(),
208                    serde_json::Value::String(name.to_string()),
209                );
210            }
211        }
212        if let Some(signature) = runtime_target.get("signature") {
213            extra_data.insert("runtime_signature".to_string(), signature.clone());
214        }
215    } else {
216        extra_data.insert(
217            "target_name".to_string(),
218            serde_json::Value::String(selected_target_name.clone()),
219        );
220        if let Some((framework, runtime_identifier)) = selected_target_name.split_once('/') {
221            extra_data.insert(
222                "target_framework".to_string(),
223                serde_json::Value::String(framework.to_string()),
224            );
225            extra_data.insert(
226                "runtime_identifier".to_string(),
227                serde_json::Value::String(runtime_identifier.to_string()),
228            );
229        } else {
230            extra_data.insert(
231                "target_framework".to_string(),
232                serde_json::Value::String(selected_target_name.clone()),
233            );
234        }
235    }
236
237    package_data.dependencies = dependencies;
238    package_data.extra_data = if extra_data.is_empty() {
239        None
240    } else {
241        Some(extra_data.into_iter().collect())
242    };
243    package_data
244}
245
246fn select_deps_target(
247    parsed: &serde_json::Value,
248) -> Option<(String, serde_json::Map<String, serde_json::Value>)> {
249    let targets = parsed.get("targets")?.as_object()?;
250
251    if let Some(runtime_target_name) = parsed
252        .get("runtimeTarget")
253        .and_then(|value| value.get("name"))
254        .and_then(|value| value.as_str())
255        && let Some(target) = targets
256            .get(runtime_target_name)
257            .and_then(|value| value.as_object())
258    {
259        return Some((runtime_target_name.to_string(), target.clone()));
260    }
261
262    if let Some((name, value)) = targets
263        .iter()
264        .find(|(name, value)| name.contains('/') && value.is_object())
265        && let Some(target) = value.as_object()
266    {
267        return Some((name.clone(), target.clone()));
268    }
269
270    targets.iter().find_map(|(name, value)| {
271        value
272            .as_object()
273            .map(|target| (name.clone(), target.clone()))
274    })
275}
276
277fn select_root_library_key(
278    path: &Path,
279    libraries: &serde_json::Map<String, serde_json::Value>,
280    target: &serde_json::Map<String, serde_json::Value>,
281) -> Option<String> {
282    let base_name = path
283        .file_name()
284        .and_then(|name| name.to_str())
285        .and_then(|name| name.strip_suffix(".deps.json"));
286
287    let project_keys: Vec<String> = target
288        .keys()
289        .filter(|key| {
290            libraries
291                .get(*key)
292                .and_then(|value| value.get("type"))
293                .and_then(|value| value.as_str())
294                == Some("project")
295        })
296        .cloned()
297        .collect();
298
299    if let Some(base_name) = base_name
300        && let Some(matched) = project_keys.iter().find(|key| {
301            split_library_key(key)
302                .map(|(name, _)| name.eq_ignore_ascii_case(base_name))
303                .unwrap_or(false)
304        })
305    {
306        return Some(matched.clone());
307    }
308
309    if project_keys.len() == 1 {
310        project_keys.into_iter().next()
311    } else {
312        None
313    }
314}
315
316fn split_library_key(key: &str) -> Option<(&str, &str)> {
317    key.rsplit_once('/')
318}