Skip to main content

deps_nuget/
lockfile.rs

1//! `packages.lock.json` parser.
2//!
3//! Every field except the package name is optional (S2): `"type": "Project"` and
4//! `"type": "CentralTransitive"` entries carry `requested` but no `resolved` at all — a
5//! required `resolved` field would abort deserialization of the *entire* file, which would
6//! only surface on multi-project solutions and not on the single-project fixture a test is
7//! most likely to use. Entries without `resolved` are simply skipped.
8//!
9//! `packages.<project_name>.lock.json` (per-project lock files, used when multiple projects
10//! share a directory) cannot be expressed as an exact name —
11//! [`NuGetLockParser::locate_lockfile`] falls back to that computed name (`<project_name>`
12//! being the manifest's own file stem, NuGet's convention) once the exact
13//! `packages.lock.json` name misses (D3, #451). This must be an exact match against *this*
14//! manifest's project name, not the first `packages.*.lock.json` found in the directory —
15//! a directory shared by multiple projects can hold several such files, and taking the
16//! first one silently attaches an unrelated project's resolved versions (#451 follow-up,
17//! tester-found regression).
18
19use deps_core::error::{DepsError, Result};
20use deps_core::lockfile::{
21    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
22    locate_lockfile_for_manifest, read_lockfile_content,
23};
24use serde::Deserialize;
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27use tower_lsp_server::ls_types::Uri;
28
29/// Mirrors `deps_core::lockfile::locate_lockfile_for_manifest`'s workspace-root search
30/// depth, so the multi-project fallback below walks exactly the same directories the
31/// exact-name search already tried.
32const MAX_WORKSPACE_DEPTH: usize = 5;
33
34pub struct NuGetLockParser;
35
36impl NuGetLockParser {
37    const LOCKFILE_NAMES: &'static [&'static str] = &["packages.lock.json"];
38}
39
40/// Falls back to *this manifest's own* multi-project lock file name —
41/// `packages.<project_name>.lock.json`, where `<project_name>` is the manifest's file stem
42/// (NuGet's convention: the project name defaults to the project file's name without its
43/// extension) — once the exact `packages.lock.json` search (same directory, then up to
44/// [`MAX_WORKSPACE_DEPTH`] parent directories) has already missed. Mirrors
45/// `locate_lockfile_for_manifest`'s own directory-walk order, but against this one computed
46/// filename rather than a fixed list — an exact match, deliberately, not "the first
47/// `packages.*.lock.json` in the directory": a directory shared by multiple projects can
48/// hold several per-project lock files, and picking the wrong one would silently attach an
49/// unrelated project's resolved versions (#451 follow-up regression).
50fn locate_multi_project_lockfile(manifest_uri: &Uri) -> Option<PathBuf> {
51    let manifest_path = manifest_uri.to_file_path()?;
52    let project_name = manifest_path.file_stem()?.to_str()?;
53    if project_name.is_empty() {
54        return None;
55    }
56    let lock_filename = format!("packages.{project_name}.lock.json");
57    let manifest_dir = manifest_path.parent()?;
58
59    let mut lock_path = manifest_dir.to_path_buf();
60    lock_path.push(&lock_filename);
61    if lock_path.is_file() {
62        return Some(lock_path);
63    }
64
65    let mut current_dir = manifest_dir.parent()?;
66    for _ in 0..MAX_WORKSPACE_DEPTH {
67        lock_path = current_dir.join(&lock_filename);
68        if lock_path.is_file() {
69            return Some(lock_path);
70        }
71        current_dir = current_dir.parent()?;
72    }
73    None
74}
75
76#[derive(Deserialize)]
77struct PackagesLock {
78    // The top-level "version" field (1 or 2) is intentionally not modeled: both schema
79    // versions are accepted without gating on its value, and serde ignores unknown fields
80    // by default, so there is nothing to read it for.
81    #[serde(default)]
82    dependencies: HashMap<String, HashMap<String, LockEntry>>,
83}
84
85#[derive(Deserialize)]
86struct LockEntry {
87    #[serde(default)]
88    resolved: Option<String>,
89    #[serde(default, rename = "contentHash")]
90    content_hash: Option<String>,
91}
92
93impl LockFileProvider for NuGetLockParser {
94    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
95        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
96            .or_else(|| locate_multi_project_lockfile(manifest_uri))
97    }
98
99    fn parse_lockfile<'a>(
100        &'a self,
101        lockfile_path: &'a Path,
102    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
103    {
104        Box::pin(async move {
105            tracing::debug!("Parsing packages.lock.json: {}", lockfile_path.display());
106
107            let content = read_lockfile_content(lockfile_path, "packages.lock.json").await?;
108
109            let lock_data: PackagesLock = deps_core::parse_json_checked(content.as_bytes())
110                .map_err(|e| DepsError::ParseError {
111                    file_type: "packages.lock.json".into(),
112                    source: Box::new(e),
113                })?;
114
115            // Collect every TFM's resolved version per package name, then resolve the
116            // cross-TFM tie-break with the crate's own `compare_versions` (S6) instead of
117            // `deps_core::lockfile::best_package`, whose `semver::Version::parse` fallback
118            // always fails on NuGet's 4-component versions and degrades to string
119            // comparison (e.g. "1.10.0" < "1.9.0"). Only the single winner is ever handed
120            // to `ResolvedPackages`, so that broken comparator is never reached.
121            let mut candidates: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
122            for packages in lock_data.dependencies.into_values() {
123                for (name, entry) in packages {
124                    // "type": "Project" / "CentralTransitive" entries carry no `resolved`
125                    // at all — skip rather than aborting the whole file (S2).
126                    if let Some(resolved) = entry.resolved {
127                        candidates
128                            .entry(name)
129                            .or_default()
130                            .push((resolved, entry.content_hash));
131                    }
132                }
133            }
134
135            let mut packages = ResolvedPackages::new();
136            for (name, versions) in candidates {
137                let best = versions
138                    .into_iter()
139                    .max_by(|a, b| crate::version::compare_versions(&a.0, &b.0));
140                if let Some((version, content_hash)) = best {
141                    packages.insert(ResolvedPackage {
142                        name,
143                        version,
144                        source: ResolvedSource::Registry {
145                            // Informational only — nothing in `deps-lsp`/`deps-core::lsp_helpers`
146                            // reads `ResolvedSource`, and this path makes no network request, so
147                            // it never routes a lockfile-resolved version against a private feed
148                            // (issue #523's config resolution intentionally stops at
149                            // `NuGetDependency::source`, not `ResolvedPackage::source`).
150                            url: crate::registry::NUGET_ORG_INDEX_URL.into(),
151                            checksum: content_hash.unwrap_or_default(),
152                        },
153                        dependencies: vec![],
154                    });
155                }
156            }
157
158            tracing::info!(
159                "Parsed packages.lock.json: {} packages from {}",
160                packages.len(),
161                lockfile_path.display()
162            );
163
164            Ok(packages)
165        })
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[tokio::test]
174    async fn test_parse_single_tfm() {
175        let content = r#"{
176  "version": 1,
177  "dependencies": {
178    "net8.0": {
179      "Newtonsoft.Json": {
180        "type": "Direct",
181        "requested": "[13.0.3, )",
182        "resolved": "13.0.3",
183        "contentHash": "abc123"
184      }
185    }
186  }
187}"#;
188        let tmp = tempfile::tempdir().unwrap();
189        let path = tmp.path().join("packages.lock.json");
190        tokio::fs::write(&path, content).await.unwrap();
191
192        let parser = NuGetLockParser;
193        let resolved = parser.parse_lockfile(&path).await.unwrap();
194        assert_eq!(resolved.len(), 1);
195        assert_eq!(resolved.get_version("Newtonsoft.Json"), Some("13.0.3"));
196    }
197
198    #[tokio::test]
199    async fn test_project_reference_entry_skipped() {
200        let content = r#"{
201  "version": 2,
202  "dependencies": {
203    "net8.0": {
204      "MyCompany.Shared": { "type": "Project" },
205      "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" }
206    }
207  }
208}"#;
209        let tmp = tempfile::tempdir().unwrap();
210        let path = tmp.path().join("packages.lock.json");
211        tokio::fs::write(&path, content).await.unwrap();
212
213        let parser = NuGetLockParser;
214        let resolved = parser.parse_lockfile(&path).await.unwrap();
215        assert_eq!(resolved.len(), 1);
216        assert!(resolved.get("MyCompany.Shared").is_none());
217        assert_eq!(resolved.get_version("Newtonsoft.Json"), Some("13.0.3"));
218    }
219
220    #[tokio::test]
221    async fn test_multi_tfm_tie_break_uses_nuget_comparator() {
222        // 4-component versions where "1.10.0.0" > "1.9.0.0" numerically but would sort the
223        // other way under a broken semver-then-string fallback.
224        let content = r#"{
225  "version": 1,
226  "dependencies": {
227    "net472": {
228      "Foo": { "type": "Direct", "resolved": "1.9.0.0" }
229    },
230    "net8.0": {
231      "Foo": { "type": "Direct", "resolved": "1.10.0.0" }
232    }
233  }
234}"#;
235        let tmp = tempfile::tempdir().unwrap();
236        let path = tmp.path().join("packages.lock.json");
237        tokio::fs::write(&path, content).await.unwrap();
238
239        let parser = NuGetLockParser;
240        let resolved = parser.parse_lockfile(&path).await.unwrap();
241        assert_eq!(resolved.get_version("Foo"), Some("1.10.0.0"));
242    }
243
244    #[tokio::test]
245    async fn test_missing_optional_fields() {
246        let content = r#"{
247  "dependencies": {
248    "net8.0": {
249      "Bare": { "resolved": "1.0.0" }
250    }
251  }
252}"#;
253        let tmp = tempfile::tempdir().unwrap();
254        let path = tmp.path().join("packages.lock.json");
255        tokio::fs::write(&path, content).await.unwrap();
256
257        let parser = NuGetLockParser;
258        let resolved = parser.parse_lockfile(&path).await.unwrap();
259        assert_eq!(resolved.get_version("Bare"), Some("1.0.0"));
260    }
261
262    #[tokio::test]
263    async fn test_invalid_json_returns_error() {
264        let tmp = tempfile::tempdir().unwrap();
265        let path = tmp.path().join("packages.lock.json");
266        tokio::fs::write(&path, b"not valid json").await.unwrap();
267
268        let parser = NuGetLockParser;
269        let result = parser.parse_lockfile(&path).await;
270        assert!(result.is_err());
271    }
272
273    #[tokio::test]
274    async fn test_nesting_at_max_depth_accepted() {
275        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
276        let content = format!(
277            r#"{{"dependencies": {{}}, "extra": {}1{}}}"#,
278            "[".repeat(depth - 1),
279            "]".repeat(depth - 1)
280        );
281        let tmp = tempfile::tempdir().unwrap();
282        let path = tmp.path().join("packages.lock.json");
283        tokio::fs::write(&path, &content).await.unwrap();
284
285        let parser = NuGetLockParser;
286        assert!(parser.parse_lockfile(&path).await.is_ok());
287    }
288
289    #[tokio::test]
290    async fn test_nesting_over_max_depth_rejected() {
291        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
292        let content = format!(
293            r#"{{"dependencies": {{}}, "extra": {}1{}}}"#,
294            "[".repeat(depth),
295            "]".repeat(depth)
296        );
297        let tmp = tempfile::tempdir().unwrap();
298        let path = tmp.path().join("packages.lock.json");
299        tokio::fs::write(&path, &content).await.unwrap();
300
301        let parser = NuGetLockParser;
302        assert!(parser.parse_lockfile(&path).await.is_err());
303    }
304
305    #[tokio::test]
306    async fn test_empty_dependencies_returns_empty() {
307        let content = r#"{"version": 1, "dependencies": {}}"#;
308        let tmp = tempfile::tempdir().unwrap();
309        let path = tmp.path().join("packages.lock.json");
310        tokio::fs::write(&path, content).await.unwrap();
311
312        let parser = NuGetLockParser;
313        let resolved = parser.parse_lockfile(&path).await.unwrap();
314        assert_eq!(resolved.len(), 0);
315    }
316
317    #[test]
318    fn test_locate_lockfile() {
319        let temp_dir = tempfile::tempdir().unwrap();
320        let manifest_path = temp_dir.path().join("App.csproj");
321        let lock_path = temp_dir.path().join("packages.lock.json");
322        std::fs::write(&manifest_path, "<Project></Project>").unwrap();
323        std::fs::write(&lock_path, "{}").unwrap();
324
325        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
326        let parser = NuGetLockParser;
327        let located = parser.locate_lockfile(&manifest_uri);
328        assert_eq!(located, Some(lock_path));
329    }
330
331    // --- locate_lockfile: multi-project fallback (D3, #451) ---
332
333    /// Regression test (tester-found, #451 follow-up): with two per-project lock files in
334    /// the same directory, each manifest must resolve to *its own* lock file by matching
335    /// the `<project>` segment against the manifest's file stem — not just the first
336    /// `packages.*.lock.json` a directory scan happens to find.
337    #[test]
338    fn test_locate_lockfile_multi_project_matches_own_project_not_first_found() {
339        let temp_dir = tempfile::tempdir().unwrap();
340        let app1_manifest = temp_dir.path().join("App1.csproj");
341        let app2_manifest = temp_dir.path().join("App2.csproj");
342        let app1_lock = temp_dir.path().join("packages.App1.lock.json");
343        let app2_lock = temp_dir.path().join("packages.App2.lock.json");
344        std::fs::write(&app1_manifest, "<Project></Project>").unwrap();
345        std::fs::write(&app2_manifest, "<Project></Project>").unwrap();
346        std::fs::write(&app1_lock, "{}").unwrap();
347        std::fs::write(&app2_lock, "{}").unwrap();
348
349        let parser = NuGetLockParser;
350        assert_eq!(
351            parser.locate_lockfile(&Uri::from_file_path(&app1_manifest).unwrap()),
352            Some(app1_lock)
353        );
354        assert_eq!(
355            parser.locate_lockfile(&Uri::from_file_path(&app2_manifest).unwrap()),
356            Some(app2_lock)
357        );
358    }
359
360    /// Same scenario as above but with only the *other* project's lock file present: must
361    /// return `None` rather than wrongly attaching an unrelated project's resolved versions.
362    #[test]
363    fn test_locate_lockfile_multi_project_does_not_match_other_projects_lock_file() {
364        let temp_dir = tempfile::tempdir().unwrap();
365        let app1_manifest = temp_dir.path().join("App1.csproj");
366        let app2_lock = temp_dir.path().join("packages.App2.lock.json");
367        std::fs::write(&app1_manifest, "<Project></Project>").unwrap();
368        std::fs::write(&app2_lock, "{}").unwrap();
369
370        let manifest_uri = Uri::from_file_path(&app1_manifest).unwrap();
371        let parser = NuGetLockParser;
372        assert_eq!(parser.locate_lockfile(&manifest_uri), None);
373    }
374
375    #[test]
376    fn test_locate_lockfile_finds_multi_project_name_in_manifest_dir() {
377        let temp_dir = tempfile::tempdir().unwrap();
378        let manifest_path = temp_dir.path().join("MyApp.csproj");
379        let lock_path = temp_dir.path().join("packages.MyApp.lock.json");
380        std::fs::write(&manifest_path, "<Project></Project>").unwrap();
381        std::fs::write(&lock_path, "{}").unwrap();
382
383        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
384        let parser = NuGetLockParser;
385        assert_eq!(parser.locate_lockfile(&manifest_uri), Some(lock_path));
386    }
387
388    #[test]
389    fn test_locate_lockfile_prefers_exact_name_over_multi_project() {
390        let temp_dir = tempfile::tempdir().unwrap();
391        let manifest_path = temp_dir.path().join("MyApp.csproj");
392        let exact_lock_path = temp_dir.path().join("packages.lock.json");
393        let multi_lock_path = temp_dir.path().join("packages.MyApp.lock.json");
394        std::fs::write(&manifest_path, "<Project></Project>").unwrap();
395        std::fs::write(&exact_lock_path, "{}").unwrap();
396        std::fs::write(&multi_lock_path, "{}").unwrap();
397
398        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
399        let parser = NuGetLockParser;
400        assert_eq!(parser.locate_lockfile(&manifest_uri), Some(exact_lock_path));
401    }
402
403    #[test]
404    fn test_locate_lockfile_finds_multi_project_name_in_workspace_parent() {
405        let temp_dir = tempfile::tempdir().unwrap();
406        let project_dir = temp_dir.path().join("src").join("MyApp");
407        std::fs::create_dir_all(&project_dir).unwrap();
408        let manifest_path = project_dir.join("MyApp.csproj");
409        let lock_path = temp_dir.path().join("packages.MyApp.lock.json");
410        std::fs::write(&manifest_path, "<Project></Project>").unwrap();
411        std::fs::write(&lock_path, "{}").unwrap();
412
413        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
414        let parser = NuGetLockParser;
415        assert_eq!(parser.locate_lockfile(&manifest_uri), Some(lock_path));
416    }
417
418    #[test]
419    fn test_locate_lockfile_no_match_returns_none() {
420        let temp_dir = tempfile::tempdir().unwrap();
421        let manifest_path = temp_dir.path().join("MyApp.csproj");
422        std::fs::write(&manifest_path, "<Project></Project>").unwrap();
423
424        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
425        let parser = NuGetLockParser;
426        assert_eq!(parser.locate_lockfile(&manifest_uri), None);
427    }
428
429    #[test]
430    fn test_locate_lockfile_ignores_unrelated_files_in_dir() {
431        let temp_dir = tempfile::tempdir().unwrap();
432        let manifest_path = temp_dir.path().join("MyApp.csproj");
433        std::fs::write(&manifest_path, "<Project></Project>").unwrap();
434        std::fs::write(temp_dir.path().join("packages.json"), "{}").unwrap();
435        std::fs::write(temp_dir.path().join("packages..lock.json"), "{}").unwrap();
436
437        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
438        let parser = NuGetLockParser;
439        assert_eq!(parser.locate_lockfile(&manifest_uri), None);
440    }
441}