Skip to main content

torsh_package/
dependency_lockfile.rs

1//! Dependency lockfile management for reproducible builds
2//!
3//! This module provides functionality for generating and validating lockfiles
4//! that ensure reproducible dependency resolution across different environments.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use torsh_core::error::{Result, TorshError};
14
15use crate::dependency::{DependencySpec, ResolvedDependency};
16use crate::version::PackageVersion;
17
18/// Lockfile format version
19pub const LOCKFILE_VERSION: &str = "1.0.0";
20
21/// Package lockfile containing resolved dependencies
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PackageLockfile {
24    /// Lockfile format version
25    pub version: String,
26    /// Root package name
27    pub package_name: String,
28    /// Root package version
29    pub package_version: String,
30    /// When the lockfile was generated
31    pub generated_at: DateTime<Utc>,
32    /// Locked dependencies with resolved versions
33    pub dependencies: Vec<LockedDependency>,
34    /// Metadata about the lockfile generation
35    pub metadata: LockfileMetadata,
36}
37
38/// Locked dependency with exact version and integrity hash
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct LockedDependency {
41    /// Package name
42    pub name: String,
43    /// Exact resolved version
44    pub version: String,
45    /// Version requirement that led to this resolution
46    pub version_req: String,
47    /// Integrity hash (SHA-256) of the package
48    pub integrity: String,
49    /// Dependencies of this package
50    pub dependencies: Vec<String>,
51    /// Whether this is an optional dependency
52    pub optional: bool,
53    /// Platform-specific requirement
54    pub platform: Option<String>,
55    /// Registry URL where package was resolved from
56    pub registry: String,
57}
58
59/// Metadata about lockfile generation
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct LockfileMetadata {
62    /// Total number of dependencies
63    pub total_dependencies: usize,
64    /// Dependency resolution strategy used
65    pub resolution_strategy: String,
66    /// Platform the lockfile was generated on
67    pub platform: String,
68    /// Generator tool and version
69    pub generator: String,
70}
71
72/// Locked package representation for flat storage
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct LockedPackage {
75    /// Package name
76    pub name: String,
77    /// Exact version
78    pub version: String,
79    /// Integrity hash
80    pub integrity: String,
81    /// Direct dependencies
82    pub dependencies: HashMap<String, String>,
83}
84
85/// Lockfile generator for creating lockfiles from resolved dependencies
86pub struct LockfileGenerator {
87    /// Whether to include optional dependencies
88    include_optional: bool,
89    /// Whether to include platform-specific dependencies
90    include_platform_specific: bool,
91}
92
93/// Lockfile validator for checking lockfile integrity
94pub struct LockfileValidator {
95    /// Whether to allow missing optional dependencies
96    allow_missing_optional: bool,
97    /// Whether to strict-check integrity hashes
98    strict_integrity: bool,
99}
100
101impl PackageLockfile {
102    /// Create a new lockfile
103    pub fn new(package_name: String, package_version: String) -> Self {
104        Self {
105            version: LOCKFILE_VERSION.to_string(),
106            package_name,
107            package_version,
108            generated_at: Utc::now(),
109            dependencies: Vec::new(),
110            metadata: LockfileMetadata {
111                total_dependencies: 0,
112                resolution_strategy: "highest".to_string(),
113                platform: std::env::consts::OS.to_string(),
114                generator: format!("torsh-package/{}", env!("CARGO_PKG_VERSION")),
115            },
116        }
117    }
118
119    /// Load lockfile from a file
120    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
121        let contents = fs::read_to_string(path)
122            .map_err(|e| TorshError::InvalidArgument(format!("Failed to read lockfile: {}", e)))?;
123
124        let lockfile: Self = serde_json::from_str(&contents)
125            .map_err(|e| TorshError::InvalidArgument(format!("Failed to parse lockfile: {}", e)))?;
126
127        // Validate lockfile format version
128        let lockfile_version = PackageVersion::parse(&lockfile.version)
129            .map_err(|e| TorshError::InvalidArgument(format!("Invalid lockfile version: {}", e)))?;
130
131        let current_version = PackageVersion::parse(LOCKFILE_VERSION)
132            .map_err(|e| TorshError::InvalidArgument(format!("Invalid current version: {}", e)))?;
133
134        if lockfile_version.major != current_version.major {
135            return Err(TorshError::InvalidArgument(format!(
136                "Incompatible lockfile version: {} (expected major version {})",
137                lockfile.version, current_version.major
138            )));
139        }
140
141        Ok(lockfile)
142    }
143
144    /// Save lockfile to a file
145    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
146        let contents = serde_json::to_string_pretty(self).map_err(|e| {
147            TorshError::InvalidArgument(format!("Failed to serialize lockfile: {}", e))
148        })?;
149
150        fs::write(path, contents)
151            .map_err(|e| TorshError::InvalidArgument(format!("Failed to write lockfile: {}", e)))?;
152
153        Ok(())
154    }
155
156    /// Add a locked dependency
157    pub fn add_dependency(&mut self, dependency: LockedDependency) {
158        self.dependencies.push(dependency);
159        self.metadata.total_dependencies = self.dependencies.len();
160    }
161
162    /// Get a dependency by name
163    pub fn get_dependency(&self, name: &str) -> Option<&LockedDependency> {
164        self.dependencies.iter().find(|d| d.name == name)
165    }
166
167    /// Get all dependencies as a map
168    pub fn get_dependency_map(&self) -> HashMap<String, String> {
169        self.dependencies
170            .iter()
171            .map(|d| (d.name.clone(), d.version.clone()))
172            .collect()
173    }
174
175    /// Check if lockfile is outdated (older than specified days)
176    pub fn is_outdated(&self, days: i64) -> bool {
177        let now = Utc::now();
178        let age = now.signed_duration_since(self.generated_at);
179        age.num_days() > days
180    }
181
182    /// Get statistics about the lockfile
183    pub fn get_statistics(&self) -> LockfileStatistics {
184        let mut optional_count = 0;
185        let mut platform_specific_count = 0;
186        let mut total_size = 0;
187
188        for dep in &self.dependencies {
189            if dep.optional {
190                optional_count += 1;
191            }
192            if dep.platform.is_some() {
193                platform_specific_count += 1;
194            }
195            // Estimate size from integrity hash (simplified)
196            total_size += 1024; // Placeholder
197        }
198
199        LockfileStatistics {
200            total_dependencies: self.dependencies.len(),
201            optional_dependencies: optional_count,
202            platform_specific_dependencies: platform_specific_count,
203            estimated_total_size: total_size,
204            generation_date: self.generated_at,
205        }
206    }
207}
208
209/// Lockfile statistics
210#[derive(Debug, Clone)]
211pub struct LockfileStatistics {
212    /// Total number of dependencies
213    pub total_dependencies: usize,
214    /// Number of optional dependencies
215    pub optional_dependencies: usize,
216    /// Number of platform-specific dependencies
217    pub platform_specific_dependencies: usize,
218    /// Estimated total download size in bytes
219    pub estimated_total_size: u64,
220    /// When the lockfile was generated
221    pub generation_date: DateTime<Utc>,
222}
223
224impl LockedDependency {
225    /// Create a new locked dependency
226    pub fn new(name: String, version: String, version_req: String, integrity: String) -> Self {
227        Self {
228            name,
229            version,
230            version_req,
231            integrity,
232            dependencies: Vec::new(),
233            optional: false,
234            platform: None,
235            registry: "https://packages.torsh.rs".to_string(),
236        }
237    }
238
239    /// Verify integrity hash against actual package data
240    pub fn verify_integrity(&self, package_data: &[u8]) -> bool {
241        let computed_hash = Self::compute_integrity(package_data);
242        self.integrity == computed_hash
243    }
244
245    /// Compute integrity hash for package data
246    pub fn compute_integrity(data: &[u8]) -> String {
247        let mut hasher = Sha256::new();
248        hasher.update(data);
249        format!("sha256-{}", hex::encode(hasher.finalize()))
250    }
251}
252
253impl Default for LockfileGenerator {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259impl LockfileGenerator {
260    /// Create a new lockfile generator
261    pub fn new() -> Self {
262        Self {
263            include_optional: true,
264            include_platform_specific: true,
265        }
266    }
267
268    /// Set whether to include optional dependencies
269    pub fn with_optional(mut self, include: bool) -> Self {
270        self.include_optional = include;
271        self
272    }
273
274    /// Set whether to include platform-specific dependencies
275    pub fn with_platform_specific(mut self, include: bool) -> Self {
276        self.include_platform_specific = include;
277        self
278    }
279
280    /// Generate lockfile from resolved dependencies
281    pub fn generate(
282        &self,
283        package_name: String,
284        package_version: String,
285        resolved: &[ResolvedDependency],
286    ) -> Result<PackageLockfile> {
287        let mut lockfile = PackageLockfile::new(package_name, package_version);
288
289        for dep in resolved {
290            // Skip optional dependencies if not included
291            if dep.spec.optional && !self.include_optional {
292                continue;
293            }
294
295            // Skip platform-specific dependencies if not included
296            if dep.spec.platform.is_some() && !self.include_platform_specific {
297                continue;
298            }
299
300            let locked_dep = LockedDependency {
301                name: dep.spec.name.clone(),
302                version: dep.resolved_version.clone(),
303                version_req: dep.spec.version_req.clone(),
304                integrity: "sha256-placeholder".to_string(), // Would compute from actual package
305                dependencies: dep
306                    .dependencies
307                    .iter()
308                    .map(|d| d.spec.name.clone())
309                    .collect(),
310                optional: dep.spec.optional,
311                platform: dep.spec.platform.clone(),
312                registry: "https://packages.torsh.rs".to_string(),
313            };
314
315            lockfile.add_dependency(locked_dep);
316        }
317
318        Ok(lockfile)
319    }
320
321    /// Generate lockfile from dependency specs
322    pub fn generate_from_specs(
323        &self,
324        package_name: String,
325        package_version: String,
326        specs: &[DependencySpec],
327    ) -> Result<PackageLockfile> {
328        // Convert specs to resolved dependencies
329        let resolved: Vec<_> = specs
330            .iter()
331            .map(|spec| ResolvedDependency {
332                spec: spec.clone(),
333                resolved_version: "0.0.0".to_string(), // Placeholder
334                package_path: None,
335                dependencies: Vec::new(),
336            })
337            .collect();
338
339        self.generate(package_name, package_version, &resolved)
340    }
341}
342
343impl Default for LockfileValidator {
344    fn default() -> Self {
345        Self::new()
346    }
347}
348
349impl LockfileValidator {
350    /// Create a new lockfile validator
351    pub fn new() -> Self {
352        Self {
353            allow_missing_optional: true,
354            strict_integrity: true,
355        }
356    }
357
358    /// Set whether to allow missing optional dependencies
359    pub fn with_allow_missing_optional(mut self, allow: bool) -> Self {
360        self.allow_missing_optional = allow;
361        self
362    }
363
364    /// Set whether to strictly check integrity hashes
365    pub fn with_strict_integrity(mut self, strict: bool) -> Self {
366        self.strict_integrity = strict;
367        self
368    }
369
370    /// Validate a lockfile
371    pub fn validate(&self, lockfile: &PackageLockfile) -> Result<ValidationReport> {
372        let mut report = ValidationReport::new();
373
374        // Check lockfile version
375        let lockfile_version = PackageVersion::parse(&lockfile.version)
376            .map_err(|e| TorshError::InvalidArgument(format!("Invalid lockfile version: {}", e)))?;
377
378        let current_version = PackageVersion::parse(LOCKFILE_VERSION)
379            .map_err(|e| TorshError::InvalidArgument(format!("Invalid current version: {}", e)))?;
380
381        if lockfile_version.major != current_version.major {
382            report.add_error(format!(
383                "Incompatible lockfile version: {} (expected major version {})",
384                lockfile.version, current_version.major
385            ));
386        }
387
388        // Check for duplicate dependencies
389        let mut seen = HashMap::new();
390        for dep in &lockfile.dependencies {
391            if let Some(existing) = seen.insert(&dep.name, &dep.version) {
392                report.add_warning(format!(
393                    "Duplicate dependency: {} (versions {} and {})",
394                    dep.name, existing, dep.version
395                ));
396            }
397        }
398
399        // Validate each dependency
400        for dep in &lockfile.dependencies {
401            self.validate_dependency(dep, &mut report)?;
402        }
403
404        // Check for circular dependencies
405        if let Err(e) = self.check_circular_dependencies(lockfile) {
406            report.add_error(format!("Circular dependency detected: {}", e));
407        }
408
409        Ok(report)
410    }
411
412    /// Validate a single dependency
413    fn validate_dependency(
414        &self,
415        dep: &LockedDependency,
416        report: &mut ValidationReport,
417    ) -> Result<()> {
418        // Validate version format
419        if PackageVersion::parse(&dep.version).is_err() {
420            report.add_error(format!("Invalid version for {}: {}", dep.name, dep.version));
421        }
422
423        // Check integrity hash format
424        if self.strict_integrity && !dep.integrity.starts_with("sha256-") {
425            report.add_error(format!(
426                "Invalid integrity hash format for {}: {}",
427                dep.name, dep.integrity
428            ));
429        }
430
431        // Check optional dependency handling
432        if dep.optional && !self.allow_missing_optional {
433            report.add_warning(format!(
434                "Optional dependency {} may not be installed",
435                dep.name
436            ));
437        }
438
439        Ok(())
440    }
441
442    /// Check for circular dependencies in the lockfile
443    fn check_circular_dependencies(&self, lockfile: &PackageLockfile) -> Result<()> {
444        let mut graph: HashMap<String, Vec<String>> = HashMap::new();
445
446        for dep in &lockfile.dependencies {
447            graph.insert(dep.name.clone(), dep.dependencies.clone());
448        }
449
450        // Perform cycle detection using DFS
451        let mut visited = HashSet::new();
452        let mut rec_stack = HashSet::new();
453
454        for dep in &lockfile.dependencies {
455            if !visited.contains(&dep.name) {
456                if self.has_cycle(&dep.name, &graph, &mut visited, &mut rec_stack)? {
457                    return Err(TorshError::InvalidArgument(format!(
458                        "Circular dependency involving: {}",
459                        dep.name
460                    )));
461                }
462            }
463        }
464
465        Ok(())
466    }
467
468    /// DFS-based cycle detection
469    fn has_cycle(
470        &self,
471        node: &str,
472        graph: &HashMap<String, Vec<String>>,
473        visited: &mut HashSet<String>,
474        rec_stack: &mut HashSet<String>,
475    ) -> Result<bool> {
476        visited.insert(node.to_string());
477        rec_stack.insert(node.to_string());
478
479        if let Some(neighbors) = graph.get(node) {
480            for neighbor in neighbors {
481                if !visited.contains(neighbor) {
482                    if self.has_cycle(neighbor, graph, visited, rec_stack)? {
483                        return Ok(true);
484                    }
485                } else if rec_stack.contains(neighbor) {
486                    return Ok(true);
487                }
488            }
489        }
490
491        rec_stack.remove(node);
492        Ok(false)
493    }
494
495    /// Compare two lockfiles and report differences
496    pub fn compare_lockfiles(&self, old: &PackageLockfile, new: &PackageLockfile) -> LockfileDiff {
497        let mut diff = LockfileDiff::new();
498
499        let old_map: HashMap<_, _> = old
500            .dependencies
501            .iter()
502            .map(|d| (&d.name, &d.version))
503            .collect();
504
505        let new_map: HashMap<_, _> = new
506            .dependencies
507            .iter()
508            .map(|d| (&d.name, &d.version))
509            .collect();
510
511        // Find added, removed, and updated dependencies
512        for (name, new_version) in &new_map {
513            if let Some(old_version) = old_map.get(name) {
514                if old_version != new_version {
515                    diff.updated.push((
516                        name.to_string(),
517                        old_version.to_string(),
518                        new_version.to_string(),
519                    ));
520                }
521            } else {
522                diff.added.push((name.to_string(), new_version.to_string()));
523            }
524        }
525
526        for (name, old_version) in &old_map {
527            if !new_map.contains_key(name) {
528                diff.removed
529                    .push((name.to_string(), old_version.to_string()));
530            }
531        }
532
533        diff
534    }
535}
536
537use std::collections::HashSet;
538
539/// Validation report for lockfile validation
540#[derive(Debug, Clone)]
541pub struct ValidationReport {
542    /// Validation errors (critical issues)
543    pub errors: Vec<String>,
544    /// Validation warnings (non-critical issues)
545    pub warnings: Vec<String>,
546}
547
548impl ValidationReport {
549    /// Create a new validation report
550    pub fn new() -> Self {
551        Self {
552            errors: Vec::new(),
553            warnings: Vec::new(),
554        }
555    }
556
557    /// Add an error
558    pub fn add_error(&mut self, error: String) {
559        self.errors.push(error);
560    }
561
562    /// Add a warning
563    pub fn add_warning(&mut self, warning: String) {
564        self.warnings.push(warning);
565    }
566
567    /// Check if validation passed
568    pub fn is_valid(&self) -> bool {
569        self.errors.is_empty()
570    }
571
572    /// Get summary of validation
573    pub fn summary(&self) -> String {
574        format!(
575            "Validation: {} errors, {} warnings",
576            self.errors.len(),
577            self.warnings.len()
578        )
579    }
580}
581
582impl Default for ValidationReport {
583    fn default() -> Self {
584        Self::new()
585    }
586}
587
588/// Difference between two lockfiles
589#[derive(Debug, Clone)]
590pub struct LockfileDiff {
591    /// Added dependencies (name, version)
592    pub added: Vec<(String, String)>,
593    /// Removed dependencies (name, version)
594    pub removed: Vec<(String, String)>,
595    /// Updated dependencies (name, old_version, new_version)
596    pub updated: Vec<(String, String, String)>,
597}
598
599impl LockfileDiff {
600    /// Create a new lockfile diff
601    pub fn new() -> Self {
602        Self {
603            added: Vec::new(),
604            removed: Vec::new(),
605            updated: Vec::new(),
606        }
607    }
608
609    /// Check if there are any differences
610    pub fn has_changes(&self) -> bool {
611        !self.added.is_empty() || !self.removed.is_empty() || !self.updated.is_empty()
612    }
613
614    /// Get summary of differences
615    pub fn summary(&self) -> String {
616        format!(
617            "{} added, {} removed, {} updated",
618            self.added.len(),
619            self.removed.len(),
620            self.updated.len()
621        )
622    }
623}
624
625impl Default for LockfileDiff {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn test_lockfile_creation() {
637        let lockfile = PackageLockfile::new("test-package".to_string(), "1.0.0".to_string());
638        assert_eq!(lockfile.package_name, "test-package");
639        assert_eq!(lockfile.package_version, "1.0.0");
640        assert_eq!(lockfile.version, LOCKFILE_VERSION);
641    }
642
643    #[test]
644    fn test_locked_dependency() {
645        let dep = LockedDependency::new(
646            "test-dep".to_string(),
647            "1.0.0".to_string(),
648            "^1.0.0".to_string(),
649            "sha256-abc123".to_string(),
650        );
651
652        assert_eq!(dep.name, "test-dep");
653        assert_eq!(dep.version, "1.0.0");
654        assert_eq!(dep.integrity, "sha256-abc123");
655    }
656
657    #[test]
658    fn test_integrity_verification() {
659        let data = b"test package data";
660        let integrity = LockedDependency::compute_integrity(data);
661
662        assert!(integrity.starts_with("sha256-"));
663
664        let dep = LockedDependency::new(
665            "test".to_string(),
666            "1.0.0".to_string(),
667            "^1.0.0".to_string(),
668            integrity,
669        );
670
671        assert!(dep.verify_integrity(data));
672        assert!(!dep.verify_integrity(b"different data"));
673    }
674
675    #[test]
676    fn test_lockfile_generator() {
677        let generator = LockfileGenerator::new().with_optional(true);
678
679        let specs = vec![DependencySpec::new(
680            "test-dep".to_string(),
681            "^1.0.0".to_string(),
682        )];
683
684        let lockfile = generator
685            .generate_from_specs("test-package".to_string(), "1.0.0".to_string(), &specs)
686            .unwrap();
687
688        assert_eq!(lockfile.dependencies.len(), 1);
689        assert_eq!(lockfile.dependencies[0].name, "test-dep");
690    }
691
692    #[test]
693    fn test_lockfile_validator() {
694        let mut lockfile = PackageLockfile::new("test-package".to_string(), "1.0.0".to_string());
695
696        let dep = LockedDependency::new(
697            "test-dep".to_string(),
698            "1.0.0".to_string(),
699            "^1.0.0".to_string(),
700            "sha256-abc123".to_string(),
701        );
702
703        lockfile.add_dependency(dep);
704
705        let validator = LockfileValidator::new();
706        let report = validator.validate(&lockfile).unwrap();
707
708        assert!(report.is_valid());
709    }
710
711    #[test]
712    fn test_lockfile_diff() {
713        let mut old = PackageLockfile::new("test-package".to_string(), "1.0.0".to_string());
714        old.add_dependency(LockedDependency::new(
715            "dep1".to_string(),
716            "1.0.0".to_string(),
717            "^1.0.0".to_string(),
718            "sha256-abc".to_string(),
719        ));
720
721        let mut new = PackageLockfile::new("test-package".to_string(), "1.0.0".to_string());
722        new.add_dependency(LockedDependency::new(
723            "dep1".to_string(),
724            "1.1.0".to_string(),
725            "^1.0.0".to_string(),
726            "sha256-def".to_string(),
727        ));
728        new.add_dependency(LockedDependency::new(
729            "dep2".to_string(),
730            "2.0.0".to_string(),
731            "^2.0.0".to_string(),
732            "sha256-ghi".to_string(),
733        ));
734
735        let validator = LockfileValidator::new();
736        let diff = validator.compare_lockfiles(&old, &new);
737
738        assert_eq!(diff.updated.len(), 1);
739        assert_eq!(diff.added.len(), 1);
740        assert!(diff.has_changes());
741    }
742}