Skip to main content

dnx_core/
lockfile.rs

1use crate::errors::{DnxError, Result};
2use crate::resolver::{DependencyGraph, ResolvedPackage};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Lockfile {
10    pub metadata: LockfileMetadata,
11    pub packages: Vec<LockedPackage>,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct LockfileMetadata {
16    pub version: u32,
17    pub generated_by: String,
18    /// Workspace member names → relative paths from root.
19    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
20    pub workspace_members: HashMap<String, String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct LockedPackage {
25    pub name: String,
26    pub version: String,
27    pub integrity: String,
28    pub resolved: String,
29    #[serde(default)]
30    pub dependencies: Vec<String>,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub peer_dependencies: Vec<String>,
33    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34    pub bin: HashMap<String, String>,
35    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36    pub has_install_script: bool,
37}
38
39#[derive(Debug, Clone)]
40pub struct ResolvedPackageInfo {
41    pub name: String,
42    pub version: String,
43    pub integrity: String,
44    pub resolved: String,
45    pub dependencies: Vec<String>,
46    pub peer_dependencies: Vec<String>,
47    pub bin: HashMap<String, String>,
48    pub has_install_script: bool,
49}
50
51#[derive(Debug, Default)]
52pub struct LockfileDiff {
53    pub added: Vec<LockedPackage>,
54    pub removed: Vec<LockedPackage>,
55    pub changed: Vec<(LockedPackage, LockedPackage)>,
56}
57
58impl Lockfile {
59    /// Creates a new empty lockfile with default metadata
60    pub fn new() -> Self {
61        Self {
62            metadata: LockfileMetadata {
63                version: 1,
64                generated_by: format!("dnx@{}", env!("CARGO_PKG_VERSION")),
65                workspace_members: HashMap::new(),
66            },
67            packages: Vec::new(),
68        }
69    }
70
71    /// Reads a lockfile from disk and parses it
72    pub fn read(path: &Path) -> Result<Self> {
73        let contents = fs::read_to_string(path).map_err(|e| {
74            DnxError::Io(format!(
75                "Failed to read lockfile at {}: {}",
76                path.display(),
77                e
78            ))
79        })?;
80
81        let mut lockfile: Self = toml::from_str(&contents)
82            .map_err(|e| DnxError::Toml(format!("Failed to parse lockfile: {}", e)))?;
83
84        // Sort packages alphabetically by name
85        lockfile.packages.sort_by(|a, b| a.name.cmp(&b.name));
86
87        Ok(lockfile)
88    }
89
90    /// Writes the lockfile to disk
91    pub fn write(&self, path: &Path) -> Result<()> {
92        let mut sorted_lockfile = self.clone();
93
94        // Sort packages alphabetically by name, then by version
95        sorted_lockfile
96            .packages
97            .sort_by(|a, b| match a.name.cmp(&b.name) {
98                std::cmp::Ordering::Equal => a.version.cmp(&b.version),
99                other => other,
100            });
101
102        let contents = toml::to_string_pretty(&sorted_lockfile)
103            .map_err(|e| DnxError::Toml(format!("Failed to serialize lockfile: {}", e)))?;
104
105        // Create parent directory if it doesn't exist
106        if let Some(parent) = path.parent() {
107            fs::create_dir_all(parent).map_err(|e| {
108                DnxError::Io(format!(
109                    "Failed to create directory {}: {}",
110                    parent.display(),
111                    e
112                ))
113            })?;
114        }
115
116        fs::write(path, contents).map_err(|e| {
117            DnxError::Io(format!(
118                "Failed to write lockfile to {}: {}",
119                path.display(),
120                e
121            ))
122        })?;
123
124        Ok(())
125    }
126
127    /// Creates a lockfile from a list of resolved packages
128    pub fn from_dependency_graph(packages: &[ResolvedPackageInfo]) -> Self {
129        let locked_packages: Vec<LockedPackage> = packages
130            .iter()
131            .map(|pkg| LockedPackage {
132                name: pkg.name.clone(),
133                version: pkg.version.clone(),
134                integrity: pkg.integrity.clone(),
135                resolved: pkg.resolved.clone(),
136                dependencies: pkg.dependencies.clone(),
137                peer_dependencies: pkg.peer_dependencies.clone(),
138                bin: pkg.bin.clone(),
139                has_install_script: pkg.has_install_script,
140            })
141            .collect();
142
143        Self {
144            metadata: LockfileMetadata {
145                version: 1,
146                generated_by: format!("dnx@{}", env!("CARGO_PKG_VERSION")),
147                workspace_members: HashMap::new(),
148            },
149            packages: locked_packages,
150        }
151    }
152
153    /// Converts the lockfile back into a DependencyGraph for the fast-path
154    /// (skipping full network resolution when the lockfile is valid).
155    pub fn to_dependency_graph(&self) -> DependencyGraph {
156        let packages = self
157            .packages
158            .iter()
159            .map(|locked| ResolvedPackage {
160                name: locked.name.clone(),
161                version: locked.version.clone(),
162                tarball_url: locked.resolved.clone(),
163                integrity: locked.integrity.clone(),
164                dependencies: locked.dependencies.clone(),
165                peer_dependencies: locked.peer_dependencies.clone(),
166                bin: locked.bin.clone(),
167                has_install_script: locked.has_install_script,
168            })
169            .collect();
170        DependencyGraph { packages }
171    }
172
173    /// Compares this lockfile with another and returns the differences
174    pub fn diff(&self, other: &Lockfile) -> LockfileDiff {
175        let mut diff = LockfileDiff::default();
176
177        // Create maps for efficient lookup
178        let self_map: HashMap<(&str, &str), &LockedPackage> = self
179            .packages
180            .iter()
181            .map(|pkg| ((pkg.name.as_str(), pkg.version.as_str()), pkg))
182            .collect();
183
184        let other_map: HashMap<(&str, &str), &LockedPackage> = other
185            .packages
186            .iter()
187            .map(|pkg| ((pkg.name.as_str(), pkg.version.as_str()), pkg))
188            .collect();
189
190        // Find added and changed packages
191        for pkg in &other.packages {
192            let key = (pkg.name.as_str(), pkg.version.as_str());
193            match self_map.get(&key) {
194                None => {
195                    // Package is in other but not in self - it's added
196                    diff.added.push(pkg.clone());
197                }
198                Some(self_pkg) => {
199                    // Package exists in both - check if it changed
200                    if pkg != *self_pkg {
201                        diff.changed.push(((*self_pkg).clone(), pkg.clone()));
202                    }
203                }
204            }
205        }
206
207        // Find removed packages
208        for pkg in &self.packages {
209            let key = (pkg.name.as_str(), pkg.version.as_str());
210            if !other_map.contains_key(&key) {
211                diff.removed.push(pkg.clone());
212            }
213        }
214
215        diff
216    }
217}
218
219impl Default for Lockfile {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225/// Verifies that a lockfile contains all dependencies from package.json
226/// and that locked versions satisfy the declared semver ranges
227pub fn verify_against_package_json(lockfile: &Lockfile, deps: &HashMap<String, String>) -> bool {
228    // Create a map of package name -> list of locked versions for efficient lookup
229    let mut locked: HashMap<&str, Vec<&str>> = HashMap::new();
230    for pkg in &lockfile.packages {
231        locked
232            .entry(pkg.name.as_str())
233            .or_default()
234            .push(pkg.version.as_str());
235    }
236
237    // Check that all dependencies from package.json are in the lockfile
238    // and that at least one locked version satisfies the declared range
239    for (dep_name, range_str) in deps {
240        match locked.get(dep_name.as_str()) {
241            None => return false,
242            Some(locked_versions) => {
243                // Verify at least one locked version satisfies the declared range
244                let range_str = range_str.trim();
245                let effective = match range_str {
246                    "" | "*" | "latest" => ">=0.0.0",
247                    s => s,
248                };
249                if let Ok(range) = node_semver::Range::parse(effective) {
250                    let any_satisfies = locked_versions.iter().any(|ver| {
251                        node_semver::Version::parse(ver)
252                            .map(|v| range.satisfies(&v))
253                            .unwrap_or(true) // accept if parsing fails
254                    });
255                    if !any_satisfies {
256                        return false;
257                    }
258                }
259                // If range parsing fails, we accept (backward compat)
260            }
261        }
262    }
263
264    true
265}
266
267/// Verify that a lockfile covers all external dependencies of a workspace.
268/// Checks both that all external deps are satisfied and that the workspace
269/// member list in the lockfile metadata matches the actual workspace.
270pub fn verify_against_workspace(
271    lockfile: &Lockfile,
272    all_external_deps: &HashMap<String, String>,
273) -> bool {
274    // First check that all external deps are satisfied
275    if !verify_against_package_json(lockfile, all_external_deps) {
276        return false;
277    }
278
279    // If the lockfile has workspace_members metadata, we accept it as-is
280    // (a mismatch in workspace structure will surface through external deps
281    // changing, which the check above already catches). This avoids requiring
282    // a full workspace structure comparison on every install.
283    true
284}
285
286/// Verify that a lockfile's workspace member list matches the given member set.
287/// This is a stricter check that ensures the workspace hasn't structurally changed.
288pub fn verify_workspace_members(
289    lockfile: &Lockfile,
290    actual_members: &HashMap<String, String>,
291) -> bool {
292    if lockfile.metadata.workspace_members.is_empty() && !actual_members.is_empty() {
293        return false;
294    }
295    if lockfile.metadata.workspace_members.len() != actual_members.len() {
296        return false;
297    }
298    for (name, path) in actual_members {
299        match lockfile.metadata.workspace_members.get(name) {
300            Some(locked_path) => {
301                // Normalise path separators for comparison
302                let locked_norm = locked_path.replace('\\', "/");
303                let actual_norm = path.replace('\\', "/");
304                if locked_norm != actual_norm {
305                    return false;
306                }
307            }
308            None => return false,
309        }
310    }
311    true
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use std::collections::HashMap;
318
319    #[test]
320    fn test_new_lockfile() {
321        let lockfile = Lockfile::new();
322        assert_eq!(lockfile.metadata.version, 1);
323        assert_eq!(
324            lockfile.metadata.generated_by,
325            format!("dnx@{}", env!("CARGO_PKG_VERSION"))
326        );
327        assert!(lockfile.packages.is_empty());
328    }
329
330    #[test]
331    fn test_from_dependency_graph() {
332        let packages = vec![
333            ResolvedPackageInfo {
334                name: "lodash".to_string(),
335                version: "4.17.21".to_string(),
336                integrity: "sha512-abc123".to_string(),
337                resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
338                dependencies: vec![],
339                peer_dependencies: vec![],
340                bin: HashMap::new(),
341                has_install_script: false,
342            },
343            ResolvedPackageInfo {
344                name: "express".to_string(),
345                version: "4.18.2".to_string(),
346                integrity: "sha512-def456".to_string(),
347                resolved: "https://registry.npmjs.org/express/-/express-4.18.2.tgz".to_string(),
348                dependencies: vec!["accepts@1.3.8".to_string()],
349                peer_dependencies: vec![],
350                bin: HashMap::new(),
351                has_install_script: false,
352            },
353        ];
354
355        let lockfile = Lockfile::from_dependency_graph(&packages);
356        assert_eq!(lockfile.packages.len(), 2);
357        assert_eq!(lockfile.packages[0].name, "lodash");
358        assert_eq!(lockfile.packages[1].name, "express");
359        assert_eq!(lockfile.packages[1].dependencies.len(), 1);
360    }
361
362    #[test]
363    fn test_diff_added() {
364        let old = Lockfile::new();
365        let mut new = Lockfile::new();
366        new.packages.push(LockedPackage {
367            name: "lodash".to_string(),
368            version: "4.17.21".to_string(),
369            integrity: "sha512-abc123".to_string(),
370            resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
371            dependencies: vec![],
372            peer_dependencies: vec![],
373            bin: HashMap::new(),
374            has_install_script: false,
375        });
376
377        let diff = old.diff(&new);
378        assert_eq!(diff.added.len(), 1);
379        assert_eq!(diff.removed.len(), 0);
380        assert_eq!(diff.changed.len(), 0);
381        assert_eq!(diff.added[0].name, "lodash");
382    }
383
384    #[test]
385    fn test_diff_removed() {
386        let mut old = Lockfile::new();
387        old.packages.push(LockedPackage {
388            name: "lodash".to_string(),
389            version: "4.17.21".to_string(),
390            integrity: "sha512-abc123".to_string(),
391            resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
392            dependencies: vec![],
393            peer_dependencies: vec![],
394            bin: HashMap::new(),
395            has_install_script: false,
396        });
397        let new = Lockfile::new();
398
399        let diff = old.diff(&new);
400        assert_eq!(diff.added.len(), 0);
401        assert_eq!(diff.removed.len(), 1);
402        assert_eq!(diff.changed.len(), 0);
403        assert_eq!(diff.removed[0].name, "lodash");
404    }
405
406    #[test]
407    fn test_diff_changed() {
408        let mut old = Lockfile::new();
409        old.packages.push(LockedPackage {
410            name: "lodash".to_string(),
411            version: "4.17.21".to_string(),
412            integrity: "sha512-abc123".to_string(),
413            resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
414            dependencies: vec![],
415            peer_dependencies: vec![],
416            bin: HashMap::new(),
417            has_install_script: false,
418        });
419
420        let mut new = Lockfile::new();
421        new.packages.push(LockedPackage {
422            name: "lodash".to_string(),
423            version: "4.17.21".to_string(),
424            integrity: "sha512-xyz789".to_string(), // Changed integrity
425            resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
426            dependencies: vec![],
427            peer_dependencies: vec![],
428            bin: HashMap::new(),
429            has_install_script: false,
430        });
431
432        let diff = old.diff(&new);
433        assert_eq!(diff.added.len(), 0);
434        assert_eq!(diff.removed.len(), 0);
435        assert_eq!(diff.changed.len(), 1);
436        assert_eq!(diff.changed[0].0.integrity, "sha512-abc123");
437        assert_eq!(diff.changed[0].1.integrity, "sha512-xyz789");
438    }
439
440    #[test]
441    fn test_verify_against_package_json() {
442        let mut lockfile = Lockfile::new();
443        lockfile.packages.push(LockedPackage {
444            name: "lodash".to_string(),
445            version: "4.17.21".to_string(),
446            integrity: "sha512-abc123".to_string(),
447            resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
448            dependencies: vec![],
449            peer_dependencies: vec![],
450            bin: HashMap::new(),
451            has_install_script: false,
452        });
453        lockfile.packages.push(LockedPackage {
454            name: "express".to_string(),
455            version: "4.18.2".to_string(),
456            integrity: "sha512-def456".to_string(),
457            resolved: "https://registry.npmjs.org/express/-/express-4.18.2.tgz".to_string(),
458            dependencies: vec![],
459            peer_dependencies: vec![],
460            bin: HashMap::new(),
461            has_install_script: false,
462        });
463
464        let mut deps = HashMap::new();
465        deps.insert("lodash".to_string(), "^4.17.21".to_string());
466        deps.insert("express".to_string(), "^4.18.2".to_string());
467
468        assert!(verify_against_package_json(&lockfile, &deps));
469
470        // Add a dependency that's not in the lockfile
471        deps.insert("react".to_string(), "^18.0.0".to_string());
472        assert!(!verify_against_package_json(&lockfile, &deps));
473    }
474
475    #[test]
476    fn test_verify_empty_deps() {
477        let lockfile = Lockfile::new();
478        let deps = HashMap::new();
479        assert!(verify_against_package_json(&lockfile, &deps));
480    }
481}