apicurio_cli/
lockfile.rs

1//! Lock file management for reproducible builds
2//!
3//! This module handles the creation, loading, and validation of lock files that ensure
4//! reproducible builds by recording exact versions, download URLs, and content hashes
5//! of all dependencies.
6//!
7//! ## Lock File Format
8//!
9//! The lock file (`apicuriolock.yaml`) contains:
10//! - Exact resolved versions of all dependencies
11//! - Download URLs used to fetch artifacts
12//! - SHA256 checksums for integrity verification
13//! - Metadata about when the lock was generated
14//! - Hash of the configuration that generated the lock
15//!
16//! ## Integrity Verification
17//!
18//! Lock files include multiple layers of integrity verification:
19//! - Configuration hash to detect config changes
20//! - File modification timestamps
21//! - SHA256 checksums of downloaded content
22//! - Lockfile format version for compatibility
23
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use std::{fs, path::Path};
27
28/// A locked dependency with exact version and integrity information
29///
30/// Represents a dependency that has been resolved to an exact version
31/// with all information needed for reproducible fetching.
32#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
33#[serde(rename_all = "camelCase")]
34pub struct LockedDependency {
35    /// Local name/alias of the dependency
36    pub name: String,
37    /// Registry name where this dependency was resolved
38    pub registry: String,
39    /// Exact resolved version (no semver ranges)
40    pub resolved_version: String,
41    /// Full URL used to download the artifact
42    pub download_url: String,
43    /// SHA256 checksum of the downloaded content
44    pub sha256: String,
45    /// Local path where the artifact is stored
46    pub output_path: String,
47    /// Group ID of the artifact
48    pub group_id: String,
49    /// Artifact ID in the registry
50    pub artifact_id: String,
51    /// Original version specification from config (e.g., "^1.0.0")
52    pub version_spec: String,
53}
54
55/// Lock file containing all resolved dependencies and metadata
56///
57/// The lock file ensures reproducible builds by recording exact versions
58/// and integrity information for all dependencies.
59#[derive(Serialize, Deserialize, Debug)]
60#[serde(rename_all = "camelCase")]
61pub struct LockFile {
62    /// List of all locked dependencies
63    pub locked_dependencies: Vec<LockedDependency>,
64    /// Version of the lockfile format for compatibility
65    pub lockfile_version: u32,
66    /// Hash of the configuration that generated this lock
67    pub config_hash: String,
68    /// Timestamp when this lock was generated (nanoseconds since epoch)
69    pub generated_at: String,
70    /// Configuration file modification time (nanoseconds since epoch)
71    pub config_modified: Option<String>,
72}
73
74impl LockFile {
75    /// Load a lock file from disk
76    ///
77    /// # Arguments
78    /// * `path` - Path to the lock file
79    ///
80    /// # Returns
81    /// Parsed lock file structure
82    ///
83    /// # Errors
84    /// Returns error if file cannot be read or parsed as valid YAML
85    pub fn load(path: &Path) -> anyhow::Result<Self> {
86        let data = fs::read_to_string(path)?;
87        let lf: LockFile = serde_yaml::from_str(&data)?;
88        Ok(lf)
89    }
90
91    /// Save the lock file to disk
92    ///
93    /// # Arguments
94    /// * `path` - Path where to save the lock file
95    ///
96    /// # Errors
97    /// Returns error if file cannot be written or serialized
98    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
99        let data = serde_yaml::to_string(self)?;
100        fs::write(path, data)?;
101        Ok(())
102    }
103
104    /// Create a new lockfile with current timestamp and version
105    ///
106    /// # Arguments
107    /// * `locked_dependencies` - Vector of resolved dependencies
108    /// * `config_hash` - Hash of the configuration that generated this lock
109    #[allow(dead_code)]
110    pub fn new(locked_dependencies: Vec<LockedDependency>, config_hash: String) -> Self {
111        Self::with_config_modified(locked_dependencies, config_hash, None)
112    }
113
114    /// Create a new lockfile with config modification time
115    ///
116    /// # Arguments
117    /// * `locked_dependencies` - Vector of resolved dependencies
118    /// * `config_hash` - Hash of the configuration
119    /// * `config_modified` - Optional config file modification timestamp
120    pub fn with_config_modified(
121        locked_dependencies: Vec<LockedDependency>,
122        config_hash: String,
123        config_modified: Option<String>,
124    ) -> Self {
125        let now = chrono::Utc::now()
126            .timestamp_nanos_opt()
127            .unwrap_or(0)
128            .to_string();
129
130        Self {
131            locked_dependencies,
132            lockfile_version: 1,
133            config_hash,
134            generated_at: now,
135            config_modified,
136        }
137    }
138
139    /// Check if this lockfile is compatible with the given config hash
140    pub fn is_compatible_with_config(&self, config_hash: &str) -> bool {
141        self.config_hash == config_hash
142    }
143
144    /// Check if the lockfile is up-to-date based on config file modification time
145    pub fn is_newer_than_config(&self, config_path: &Path) -> anyhow::Result<bool> {
146        if let Some(config_modified_str) = &self.config_modified {
147            if let Ok(config_modified_nanos) = config_modified_str.parse::<i64>() {
148                if let Ok(metadata) = fs::metadata(config_path) {
149                    if let Ok(actual_modified) = metadata.modified() {
150                        let actual_nanos = chrono::DateTime::<chrono::Utc>::from(actual_modified)
151                            .timestamp_nanos_opt()
152                            .unwrap_or(0);
153                        return Ok(config_modified_nanos >= actual_nanos);
154                    }
155                }
156            }
157        }
158        // If we can't determine modification times, fall back to hash comparison
159        Ok(false)
160    }
161
162    /// Enhanced lockfile validation that checks multiple criteria
163    #[allow(dead_code)]
164    pub fn is_up_to_date(
165        &self,
166        config_path: &Path,
167        current_config_hash: &str,
168        dependencies: &[LockedDependency],
169    ) -> anyhow::Result<bool> {
170        // 1. Check config hash compatibility
171        if !self.is_compatible_with_config(current_config_hash) {
172            return Ok(false);
173        }
174
175        // 2. Check if config file was modified after lockfile was generated
176        if !self.is_newer_than_config(config_path)? {
177            return Ok(false);
178        }
179
180        // 3. Check that dependencies match exactly
181        if !self.dependencies_match(dependencies) {
182            return Ok(false);
183        }
184
185        Ok(true)
186    }
187
188    /// Compare two sets of locked dependencies, accounting for order independence
189    #[allow(dead_code)]
190    pub fn dependencies_match(&self, other_deps: &[LockedDependency]) -> bool {
191        if self.locked_dependencies.len() != other_deps.len() {
192            return false;
193        }
194
195        // Create maps for order-independent comparison
196        let self_map: std::collections::HashMap<&str, &LockedDependency> = self
197            .locked_dependencies
198            .iter()
199            .map(|d| (d.name.as_str(), d))
200            .collect();
201        let other_map: std::collections::HashMap<&str, &LockedDependency> =
202            other_deps.iter().map(|d| (d.name.as_str(), d)).collect();
203
204        // Check that all dependencies match exactly
205        self_map.len() == other_map.len()
206            && self_map.iter().all(|(name, dep)| {
207                other_map
208                    .get(name)
209                    .is_some_and(|other_dep| **dep == **other_dep)
210            })
211    }
212
213    /// Compute a hash of the relevant configuration that affects locking
214    /// This focuses only on the dependency specifications, not formatting/comments
215    pub fn compute_config_hash(
216        config_content: &str,
217        dependencies: &[crate::config::DependencyConfig],
218    ) -> String {
219        let mut hasher = Sha256::new();
220
221        // Only hash the dependency specifications in a deterministic order
222        // This avoids regeneration due to formatting/comment changes
223        let mut dep_specs: Vec<String> = dependencies
224            .iter()
225            .map(|d| {
226                format!(
227                    "{}:{}:{}:{}:{}:{}",
228                    d.name, d.group_id, d.artifact_id, d.version, d.registry, d.output_path
229                )
230            })
231            .collect();
232        dep_specs.sort();
233
234        for spec in dep_specs {
235            hasher.update(spec.as_bytes());
236        }
237
238        // Also include a simplified version of other config that affects dependency resolution
239        // Parse the config to extract only relevant fields
240        if let Ok(config) = serde_yaml::from_str::<crate::config::RepoConfig>(config_content) {
241            // Include registry configurations as they affect resolution
242            let mut registry_specs: Vec<String> = config
243                .registries
244                .iter()
245                .map(|r| format!("{}:{}", r.name, r.url))
246                .collect();
247            registry_specs.sort();
248
249            for spec in registry_specs {
250                hasher.update(spec.as_bytes());
251            }
252
253            // Include external registries file path if present
254            if let Some(ext_file) = &config.external_registries_file {
255                hasher.update(ext_file.as_bytes());
256            }
257        }
258
259        hex::encode(hasher.finalize())
260    }
261
262    /// Get the modification time of a config file as nanoseconds since epoch
263    pub fn get_config_modification_time(config_path: &Path) -> anyhow::Result<String> {
264        let metadata = fs::metadata(config_path)?;
265        let modified = metadata.modified()?;
266        let nanos = chrono::DateTime::<chrono::Utc>::from(modified)
267            .timestamp_nanos_opt()
268            .unwrap_or(0);
269        Ok(nanos.to_string())
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn create_test_config(dependencies: &[(&str, &str, &str, &str, &str, &str)]) -> String {
278        let mut deps = String::new();
279        for (name, group_id, artifact_id, version, registry, output_path) in dependencies {
280            deps.push_str(&format!(
281                r#"
282  - name: "{name}"
283    groupId: "{group_id}"
284    artifactId: "{artifact_id}"
285    version: "{version}"
286    registry: "{registry}"
287    outputPath: "{output_path}"
288"#
289            ));
290        }
291
292        format!(
293            r#"externalRegistriesFile: null
294registries: []
295dependencies:{deps}"#
296        )
297    }
298
299    fn create_test_locked_dependency(
300        name: &str,
301        registry: &str,
302        resolved_version: &str,
303        group_id: &str,
304        artifact_id: &str,
305        version_spec: &str,
306    ) -> LockedDependency {
307        LockedDependency {
308            name: name.to_string(),
309            registry: registry.to_string(),
310            resolved_version: resolved_version.to_string(),
311            download_url: format!(
312                "https://example.com/{group_id}/{artifact_id}/{resolved_version}"
313            ),
314            sha256: "dummy_hash".to_string(),
315            output_path: "./protos".to_string(),
316            group_id: group_id.to_string(),
317            artifact_id: artifact_id.to_string(),
318            version_spec: version_spec.to_string(),
319        }
320    }
321
322    #[test]
323    fn test_config_hash_computation() {
324        let config1 = create_test_config(&[(
325            "dep1",
326            "com.example",
327            "service1",
328            "1.0.0",
329            "registry1",
330            "./protos",
331        )]);
332
333        let config2 = create_test_config(&[(
334            "dep1",
335            "com.example",
336            "service1",
337            "1.0.0",
338            "registry1",
339            "./protos",
340        )]);
341
342        let config3 = create_test_config(&[(
343            "dep1",
344            "com.example",
345            "service1",
346            "1.1.0",
347            "registry1",
348            "./protos",
349        )]);
350
351        use crate::config::DependencyConfig;
352        let deps1 = vec![DependencyConfig {
353            name: "dep1".to_string(),
354            group_id: "com.example".to_string(),
355            artifact_id: "service1".to_string(),
356            version: "1.0.0".to_string(),
357            registry: "registry1".to_string(),
358            output_path: "./protos".to_string(),
359        }];
360
361        let deps3 = vec![DependencyConfig {
362            name: "dep1".to_string(),
363            group_id: "com.example".to_string(),
364            artifact_id: "service1".to_string(),
365            version: "1.1.0".to_string(),
366            registry: "registry1".to_string(),
367            output_path: "./protos".to_string(),
368        }];
369
370        let hash1 = LockFile::compute_config_hash(&config1, &deps1);
371        let hash2 = LockFile::compute_config_hash(&config2, &deps1);
372        let hash3 = LockFile::compute_config_hash(&config3, &deps3);
373
374        assert_eq!(hash1, hash2, "Same config should produce same hash");
375        assert_ne!(
376            hash1, hash3,
377            "Different config should produce different hash"
378        );
379    }
380
381    #[test]
382    fn test_dependencies_match_order_independence() {
383        let dep1 = create_test_locked_dependency(
384            "dep1",
385            "reg1",
386            "1.0.0",
387            "com.example",
388            "service1",
389            "^1.0",
390        );
391        let dep2 = create_test_locked_dependency(
392            "dep2",
393            "reg1",
394            "2.0.0",
395            "com.example",
396            "service2",
397            "^2.0",
398        );
399
400        let deps_order1 = vec![dep1.clone(), dep2.clone()];
401        let deps_order2 = vec![dep2.clone(), dep1.clone()];
402
403        let lockfile = LockFile::new(deps_order1.clone(), "test_hash".to_string());
404
405        assert!(lockfile.dependencies_match(&deps_order1));
406        assert!(
407            lockfile.dependencies_match(&deps_order2),
408            "Order should not matter"
409        );
410    }
411
412    #[test]
413    fn test_dependencies_match_different_content() {
414        let dep1 = create_test_locked_dependency(
415            "dep1",
416            "reg1",
417            "1.0.0",
418            "com.example",
419            "service1",
420            "^1.0",
421        );
422        let dep2 = create_test_locked_dependency(
423            "dep2",
424            "reg1",
425            "2.0.0",
426            "com.example",
427            "service2",
428            "^2.0",
429        );
430        let dep1_modified = create_test_locked_dependency(
431            "dep1",
432            "reg1",
433            "1.1.0",
434            "com.example",
435            "service1",
436            "^1.0",
437        );
438
439        let deps1 = vec![dep1.clone(), dep2.clone()];
440        let deps2 = vec![dep1_modified, dep2.clone()];
441
442        let lockfile = LockFile::new(deps1.clone(), "test_hash".to_string());
443
444        assert!(lockfile.dependencies_match(&deps1));
445        assert!(
446            !lockfile.dependencies_match(&deps2),
447            "Different versions should not match"
448        );
449    }
450
451    #[test]
452    fn test_config_compatibility() {
453        let dep1 = create_test_locked_dependency(
454            "dep1",
455            "reg1",
456            "1.0.0",
457            "com.example",
458            "service1",
459            "^1.0",
460        );
461
462        let lockfile = LockFile::new(vec![dep1], "test_hash".to_string());
463
464        assert!(lockfile.is_compatible_with_config("test_hash"));
465        assert!(!lockfile.is_compatible_with_config("different_hash"));
466    }
467
468    #[test]
469    fn test_lockfile_serialization() {
470        let dep1 = create_test_locked_dependency(
471            "dep1",
472            "reg1",
473            "1.0.0",
474            "com.example",
475            "service1",
476            "^1.0",
477        );
478        let lockfile = LockFile::new(vec![dep1], "test_hash".to_string());
479
480        let serialized = serde_yaml::to_string(&lockfile).unwrap();
481        let deserialized: LockFile = serde_yaml::from_str(&serialized).unwrap();
482
483        assert_eq!(lockfile.config_hash, deserialized.config_hash);
484        assert_eq!(lockfile.lockfile_version, deserialized.lockfile_version);
485        assert_eq!(
486            lockfile.locked_dependencies.len(),
487            deserialized.locked_dependencies.len()
488        );
489        assert!(lockfile.dependencies_match(&deserialized.locked_dependencies));
490    }
491
492    #[test]
493    fn test_empty_dependencies() {
494        let lockfile = LockFile::new(vec![], "test_hash".to_string());
495
496        assert!(lockfile.dependencies_match(&[]));
497        assert!(
498            !lockfile.dependencies_match(&[create_test_locked_dependency(
499                "dep1",
500                "reg1",
501                "1.0.0",
502                "com.example",
503                "service1",
504                "^1.0"
505            )])
506        );
507    }
508
509    #[test]
510    fn test_missing_dependency() {
511        let dep1 = create_test_locked_dependency(
512            "dep1",
513            "reg1",
514            "1.0.0",
515            "com.example",
516            "service1",
517            "^1.0",
518        );
519        let dep2 = create_test_locked_dependency(
520            "dep2",
521            "reg1",
522            "2.0.0",
523            "com.example",
524            "service2",
525            "^2.0",
526        );
527
528        let lockfile = LockFile::new(vec![dep1.clone(), dep2.clone()], "test_hash".to_string());
529
530        assert!(!lockfile.dependencies_match(&[dep1])); // Missing dep2
531        assert!(!lockfile.dependencies_match(&[dep2])); // Missing dep1
532    }
533
534    #[test]
535    fn test_config_hash_deterministic_ordering() {
536        // Test that dependency order in config doesn't affect hash
537        let deps1 = vec![
538            crate::config::DependencyConfig {
539                name: "dep_a".to_string(),
540                group_id: "com.example".to_string(),
541                artifact_id: "service_a".to_string(),
542                version: "1.0.0".to_string(),
543                registry: "registry1".to_string(),
544                output_path: "./protos".to_string(),
545            },
546            crate::config::DependencyConfig {
547                name: "dep_b".to_string(),
548                group_id: "com.example".to_string(),
549                artifact_id: "service_b".to_string(),
550                version: "2.0.0".to_string(),
551                registry: "registry1".to_string(),
552                output_path: "./protos".to_string(),
553            },
554        ];
555
556        let deps2 = vec![deps1[1].clone(), deps1[0].clone()]; // Reverse order
557
558        let config_content = "test config";
559        let hash1 = LockFile::compute_config_hash(config_content, &deps1);
560        let hash2 = LockFile::compute_config_hash(config_content, &deps2);
561
562        assert_eq!(hash1, hash2, "Config hash should be order-independent");
563    }
564
565    #[test]
566    fn test_enhanced_config_hash_ignores_formatting() {
567        // Test that the improved hash function ignores formatting differences
568        let deps = vec![crate::config::DependencyConfig {
569            name: "dep1".to_string(),
570            group_id: "com.example".to_string(),
571            artifact_id: "service1".to_string(),
572            version: "1.0.0".to_string(),
573            registry: "registry1".to_string(),
574            output_path: "./protos".to_string(),
575        }];
576
577        // These configs have different formatting but same semantic content
578        let config1 = r#"
579externalRegistriesFile: null
580registries: []
581dependencies:
582  - name: dep1
583    groupId: com.example
584    artifactId: service1
585    version: "1.0.0"
586    registry: registry1
587    outputPath: ./protos
588"#;
589
590        let config2 = r#"
591externalRegistriesFile: null
592registries: []
593# This is a comment
594dependencies:
595  - name: dep1
596    groupId: com.example
597    artifactId: service1
598    version: "1.0.0"
599    registry: registry1
600    outputPath: ./protos
601# Another comment
602"#;
603
604        let hash1 = LockFile::compute_config_hash(config1, &deps);
605        let hash2 = LockFile::compute_config_hash(config2, &deps);
606
607        assert_eq!(
608            hash1, hash2,
609            "Config hash should ignore comments and formatting"
610        );
611    }
612
613    #[test]
614    fn test_with_config_modified() {
615        let dep1 = create_test_locked_dependency(
616            "dep1",
617            "reg1",
618            "1.0.0",
619            "com.example",
620            "service1",
621            "^1.0",
622        );
623        let config_modified = Some("1234567890123456789".to_string());
624
625        let lockfile = LockFile::with_config_modified(
626            vec![dep1],
627            "test_hash".to_string(),
628            config_modified.clone(),
629        );
630
631        assert_eq!(lockfile.config_modified, config_modified);
632        assert!(lockfile.generated_at.parse::<i64>().is_ok());
633    }
634
635    #[test]
636    fn test_is_newer_than_config_with_missing_data() {
637        let dep1 = create_test_locked_dependency(
638            "dep1",
639            "reg1",
640            "1.0.0",
641            "com.example",
642            "service1",
643            "^1.0",
644        );
645
646        // Test lockfile without config_modified
647        let lockfile = LockFile::new(vec![dep1.clone()], "test_hash".to_string());
648        let result = lockfile
649            .is_newer_than_config(Path::new("nonexistent"))
650            .unwrap();
651        assert!(
652            !result,
653            "Should return false when config_modified is missing"
654        );
655
656        // Test lockfile with invalid config_modified
657        let mut lockfile_invalid = LockFile::new(vec![dep1], "test_hash".to_string());
658        lockfile_invalid.config_modified = Some("invalid_number".to_string());
659        let result = lockfile_invalid
660            .is_newer_than_config(Path::new("nonexistent"))
661            .unwrap();
662        assert!(
663            !result,
664            "Should return false when config_modified is invalid"
665        );
666    }
667
668    #[test]
669    fn test_lockfile_backwards_compatibility() {
670        // Test that old lockfiles without config_modified still work
671        let old_lockfile_yaml = r#"
672lockfileVersion: 1
673configHash: "test_hash"
674generatedAt: "1234567890"
675lockedDependencies:
676  - name: "dep1"
677    registry: "reg1"
678    resolvedVersion: "1.0.0"
679    downloadUrl: "https://example.com/dep1"
680    sha256: "dummy_hash"
681    outputPath: "./protos"
682    groupId: "com.example"
683    artifactId: "service1"
684    versionSpec: "^1.0"
685"#;
686
687        let lockfile: LockFile = serde_yaml::from_str(old_lockfile_yaml).unwrap();
688        assert!(lockfile.config_modified.is_none());
689        assert_eq!(lockfile.config_hash, "test_hash");
690        assert_eq!(lockfile.locked_dependencies.len(), 1);
691    }
692
693    #[test]
694    fn test_robust_dependency_matching() {
695        let dep1_v1 = create_test_locked_dependency(
696            "dep1",
697            "reg1",
698            "1.0.0",
699            "com.example",
700            "service1",
701            "^1.0",
702        );
703        let dep1_v2 = create_test_locked_dependency(
704            "dep1",
705            "reg1",
706            "1.0.1",
707            "com.example",
708            "service1",
709            "^1.0",
710        );
711        let dep2 = create_test_locked_dependency(
712            "dep2",
713            "reg1",
714            "2.0.0",
715            "com.example",
716            "service2",
717            "^2.0",
718        );
719
720        let lockfile = LockFile::new(vec![dep1_v1.clone(), dep2.clone()], "test_hash".to_string());
721
722        // Exact match should work
723        assert!(lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone()]));
724        assert!(lockfile.dependencies_match(&[dep2.clone(), dep1_v1.clone()])); // Order independence
725
726        // Different version should fail
727        assert!(!lockfile.dependencies_match(&[dep1_v2, dep2.clone()]));
728
729        // Missing dependency should fail
730        assert!(!lockfile.dependencies_match(&[dep1_v1.clone()]));
731
732        // Extra dependency should fail
733        let dep3 = create_test_locked_dependency(
734            "dep3",
735            "reg1",
736            "3.0.0",
737            "com.example",
738            "service3",
739            "^3.0",
740        );
741        assert!(!lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone(), dep3]));
742    }
743}