Skip to main content

cuenv_ci/flake/
analyzer.rs

1//! Flake.lock purity analysis
2//!
3//! Analyzes flake.lock files to detect unlocked inputs and compute
4//! deterministic digests from locked content hashes.
5
6use super::error::FlakeLockError;
7use super::lock::{FlakeLock, FlakeNode, InputRef};
8use sha2::{Digest, Sha256};
9use std::collections::HashSet;
10use std::path::Path;
11
12/// Result of flake.lock purity analysis
13#[derive(Debug, Clone)]
14pub struct PurityAnalysis {
15    /// Whether all inputs are properly locked
16    pub is_pure: bool,
17
18    /// List of unlocked inputs with reasons
19    pub unlocked_inputs: Vec<UnlockedInput>,
20
21    /// Computed digest from locked inputs (for cache key)
22    /// Format: "sha256:<hex>"
23    pub locked_digest: String,
24}
25
26/// An input that is not properly locked
27#[derive(Debug, Clone)]
28pub struct UnlockedInput {
29    /// Input name/path (e.g., "nixpkgs" or "rust-overlay/nixpkgs")
30    pub name: String,
31
32    /// Reason why this input is considered unlocked
33    pub reason: UnlockReason,
34}
35
36/// Reasons why an input may be unlocked
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum UnlockReason {
39    /// No `locked` section present in the input
40    MissingLockedSection,
41
42    /// `locked.narHash` is missing (required for reproducibility)
43    MissingNarHash,
44
45    /// Input uses `follows` but the target is unlocked
46    FollowsUnlocked {
47        /// The target input that is unlocked
48        target: String,
49    },
50
51    /// Input has a branch `ref` but no pinned `rev`
52    UnpinnedReference {
53        /// The unpinned reference (e.g., "nixos-unstable")
54        reference: String,
55    },
56}
57
58impl std::fmt::Display for UnlockReason {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::MissingLockedSection => write!(f, "missing locked section"),
62            Self::MissingNarHash => write!(f, "missing narHash"),
63            Self::FollowsUnlocked { target } => write!(f, "follows unlocked input '{target}'"),
64            Self::UnpinnedReference { reference } => {
65                write!(f, "unpinned reference '{reference}'")
66            }
67        }
68    }
69}
70
71/// Analyzer for flake.lock purity
72pub struct FlakeLockAnalyzer {
73    lock: FlakeLock,
74}
75
76impl FlakeLockAnalyzer {
77    /// Create analyzer from parsed `FlakeLock`
78    #[must_use]
79    pub const fn new(lock: FlakeLock) -> Self {
80        Self { lock }
81    }
82
83    /// Parse and create analyzer from JSON string
84    ///
85    /// # Errors
86    /// Returns an error if the JSON is invalid or doesn't match the schema
87    pub fn from_json(json: &str) -> Result<Self, FlakeLockError> {
88        let lock = FlakeLock::from_json(json).map_err(|e| FlakeLockError::parse(e.to_string()))?;
89        Ok(Self::new(lock))
90    }
91
92    /// Parse and create analyzer from file path
93    ///
94    /// # Errors
95    /// Returns an error if the file cannot be read or parsed
96    pub fn from_path(path: &Path) -> Result<Self, FlakeLockError> {
97        if !path.exists() {
98            return Err(FlakeLockError::missing(path));
99        }
100
101        let content =
102            std::fs::read_to_string(path).map_err(|e| FlakeLockError::io(path, e.to_string()))?;
103        Self::from_json(&content)
104    }
105
106    /// Analyze the flake.lock for purity
107    ///
108    /// Returns a `PurityAnalysis` containing:
109    /// - Whether all inputs are pure (locked)
110    /// - List of unlocked inputs with reasons
111    /// - Deterministic digest computed from all locked `narHash` values
112    #[must_use]
113    pub fn analyze(&self) -> PurityAnalysis {
114        let mut unlocked_inputs = Vec::new();
115        let mut locked_hashes = Vec::new();
116        let mut checked_nodes: HashSet<String> = HashSet::new();
117
118        // Get root node and check all its inputs
119        if let Some(root) = self.lock.nodes.get(&self.lock.root) {
120            for (input_name, input_ref) in &root.inputs {
121                self.check_input(
122                    input_name,
123                    input_ref,
124                    &mut unlocked_inputs,
125                    &mut locked_hashes,
126                    &mut checked_nodes,
127                );
128            }
129        }
130
131        // Compute deterministic digest from all locked hashes
132        let locked_digest = Self::compute_locked_digest(&locked_hashes);
133
134        PurityAnalysis {
135            is_pure: unlocked_inputs.is_empty(),
136            unlocked_inputs,
137            locked_digest,
138        }
139    }
140
141    /// Check an input reference recursively
142    fn check_input(
143        &self,
144        name: &str,
145        input_ref: &InputRef,
146        unlocked: &mut Vec<UnlockedInput>,
147        hashes: &mut Vec<String>,
148        checked: &mut HashSet<String>,
149    ) {
150        match input_ref {
151            InputRef::Direct(node_name) => {
152                // Skip if already checked (handles cycles)
153                if checked.contains(node_name) {
154                    return;
155                }
156                checked.insert(node_name.clone());
157
158                if let Some(input) = self.lock.nodes.get(node_name) {
159                    // Only check input nodes (not root)
160                    if input.is_input() {
161                        self.check_input_node(name, input, unlocked, hashes, checked);
162                    }
163                }
164            }
165            InputRef::Follows(path) => {
166                // Follows references inherit from another input
167                // Resolve the target and check if it's locked
168                if let Some(target_name) = path.first()
169                    && let Some(target) = self.lock.nodes.get(target_name)
170                {
171                    // Check if the target is unlocked (has no locked section)
172                    if target.is_input() && target.locked.is_none() {
173                        unlocked.push(UnlockedInput {
174                            name: name.to_string(),
175                            reason: UnlockReason::FollowsUnlocked {
176                                target: target_name.clone(),
177                            },
178                        });
179                    }
180                }
181            }
182        }
183    }
184
185    /// Check an input node for purity
186    fn check_input_node(
187        &self,
188        input_name: &str,
189        input: &FlakeNode,
190        unlocked: &mut Vec<UnlockedInput>,
191        hashes: &mut Vec<String>,
192        checked: &mut HashSet<String>,
193    ) {
194        // Check 1: Missing locked section
195        let Some(locked) = &input.locked else {
196            unlocked.push(UnlockedInput {
197                name: input_name.to_string(),
198                reason: UnlockReason::MissingLockedSection,
199            });
200            return;
201        };
202
203        // Check 2: Missing narHash (critical for reproducibility)
204        let Some(nar_hash) = &locked.nar_hash else {
205            unlocked.push(UnlockedInput {
206                name: input_name.to_string(),
207                reason: UnlockReason::MissingNarHash,
208            });
209            return;
210        };
211
212        // Check 3: Has ref but no rev (unpinned branch reference)
213        // This is actually OK in Nix - if narHash exists, it's pinned
214        // But we warn if original.ref exists without locked.rev for transparency
215        if let Some(original) = &input.original
216            && original.reference.is_some()
217            && locked.rev.is_none()
218        {
219            // Only warn if narHash is also missing - if narHash exists, it's still pure
220            // Actually, with narHash present, this is fine. Skip this check.
221        }
222
223        // Input is properly locked - add hash to list
224        hashes.push(nar_hash.clone());
225
226        // Recursively check transitive inputs
227        for (sub_name, sub_ref) in &input.inputs {
228            let full_name = format!("{input_name}/{sub_name}");
229            self.check_input(&full_name, sub_ref, unlocked, hashes, checked);
230        }
231    }
232
233    /// Compute a deterministic digest from all locked hashes
234    fn compute_locked_digest(input_hashes: &[String]) -> String {
235        let mut sha_hasher = Sha256::new();
236
237        // Sort hashes for deterministic ordering
238        let mut sorted_hashes = input_hashes.to_vec();
239        sorted_hashes.sort();
240
241        for hash in sorted_hashes {
242            sha_hasher.update(hash.as_bytes());
243            sha_hasher.update([0u8]); // separator
244        }
245
246        format!("sha256:{}", hex::encode(sha_hasher.finalize()))
247    }
248
249    /// Get the underlying `FlakeLock`
250    #[must_use]
251    pub const fn lock(&self) -> &FlakeLock {
252        &self.lock
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_analyze_minimal_pure() {
262        let json = r#"{
263            "nodes": {
264                "root": { "inputs": {} }
265            },
266            "root": "root",
267            "version": 7
268        }"#;
269
270        let analyzer = FlakeLockAnalyzer::from_json(json).unwrap();
271        let analysis = analyzer.analyze();
272
273        assert!(analysis.is_pure);
274        assert!(analysis.unlocked_inputs.is_empty());
275    }
276
277    #[test]
278    fn test_detect_missing_locked_section() {
279        let json = r#"{
280            "nodes": {
281                "nixpkgs": {
282                    "original": { "type": "github", "owner": "NixOS", "repo": "nixpkgs" }
283                },
284                "root": { "inputs": { "nixpkgs": "nixpkgs" } }
285            },
286            "root": "root",
287            "version": 7
288        }"#;
289
290        let analyzer = FlakeLockAnalyzer::from_json(json).unwrap();
291        let analysis = analyzer.analyze();
292
293        assert!(!analysis.is_pure);
294        assert_eq!(analysis.unlocked_inputs.len(), 1);
295        assert_eq!(analysis.unlocked_inputs[0].name, "nixpkgs");
296        assert!(matches!(
297            analysis.unlocked_inputs[0].reason,
298            UnlockReason::MissingLockedSection
299        ));
300    }
301
302    #[test]
303    fn test_detect_missing_nar_hash() {
304        let json = r#"{
305            "nodes": {
306                "nixpkgs": {
307                    "locked": {
308                        "type": "github",
309                        "owner": "NixOS",
310                        "repo": "nixpkgs",
311                        "rev": "abc123"
312                    },
313                    "original": { "type": "github", "owner": "NixOS", "repo": "nixpkgs" }
314                },
315                "root": { "inputs": { "nixpkgs": "nixpkgs" } }
316            },
317            "root": "root",
318            "version": 7
319        }"#;
320
321        let analyzer = FlakeLockAnalyzer::from_json(json).unwrap();
322        let analysis = analyzer.analyze();
323
324        assert!(!analysis.is_pure);
325        assert_eq!(analysis.unlocked_inputs.len(), 1);
326        assert!(matches!(
327            analysis.unlocked_inputs[0].reason,
328            UnlockReason::MissingNarHash
329        ));
330    }
331
332    #[test]
333    fn test_fully_locked_is_pure() {
334        let json = r#"{
335            "nodes": {
336                "nixpkgs": {
337                    "locked": {
338                        "type": "github",
339                        "owner": "NixOS",
340                        "repo": "nixpkgs",
341                        "rev": "abc123",
342                        "narHash": "sha256-xxxxxxxxxxxxx"
343                    },
344                    "original": { "type": "github", "owner": "NixOS", "repo": "nixpkgs" }
345                },
346                "root": { "inputs": { "nixpkgs": "nixpkgs" } }
347            },
348            "root": "root",
349            "version": 7
350        }"#;
351
352        let analyzer = FlakeLockAnalyzer::from_json(json).unwrap();
353        let analysis = analyzer.analyze();
354
355        assert!(analysis.is_pure);
356        assert!(analysis.unlocked_inputs.is_empty());
357        assert!(analysis.locked_digest.starts_with("sha256:"));
358    }
359
360    #[test]
361    fn test_follows_unlocked_target() {
362        let json = r#"{
363            "nodes": {
364                "nixpkgs": {
365                    "original": { "type": "github", "owner": "NixOS", "repo": "nixpkgs" }
366                },
367                "rust-overlay": {
368                    "inputs": { "nixpkgs": ["nixpkgs"] },
369                    "locked": {
370                        "type": "github",
371                        "owner": "oxalica",
372                        "repo": "rust-overlay",
373                        "rev": "def456",
374                        "narHash": "sha256-yyyyyyyyy"
375                    }
376                },
377                "root": {
378                    "inputs": {
379                        "nixpkgs": "nixpkgs",
380                        "rust-overlay": "rust-overlay"
381                    }
382                }
383            },
384            "root": "root",
385            "version": 7
386        }"#;
387
388        let analyzer = FlakeLockAnalyzer::from_json(json).unwrap();
389        let analysis = analyzer.analyze();
390
391        assert!(!analysis.is_pure);
392        // Should detect both: nixpkgs is unlocked, and rust-overlay follows unlocked nixpkgs
393        assert!(analysis.unlocked_inputs.iter().any(|u| u.name == "nixpkgs"));
394    }
395
396    #[test]
397    fn test_digest_determinism() {
398        let json = r#"{
399            "nodes": {
400                "a": {
401                    "locked": { "type": "github", "narHash": "sha256-aaa" }
402                },
403                "b": {
404                    "locked": { "type": "github", "narHash": "sha256-bbb" }
405                },
406                "root": {
407                    "inputs": { "a": "a", "b": "b" }
408                }
409            },
410            "root": "root",
411            "version": 7
412        }"#;
413
414        let analyzer1 = FlakeLockAnalyzer::from_json(json).unwrap();
415        let analyzer2 = FlakeLockAnalyzer::from_json(json).unwrap();
416
417        let analysis1 = analyzer1.analyze();
418        let analysis2 = analyzer2.analyze();
419
420        assert_eq!(analysis1.locked_digest, analysis2.locked_digest);
421    }
422
423    #[test]
424    fn test_digest_changes_with_different_hashes() {
425        let json1 = r#"{
426            "nodes": {
427                "nixpkgs": {
428                    "locked": { "type": "github", "narHash": "sha256-version1" }
429                },
430                "root": { "inputs": { "nixpkgs": "nixpkgs" } }
431            },
432            "root": "root",
433            "version": 7
434        }"#;
435
436        let json2 = r#"{
437            "nodes": {
438                "nixpkgs": {
439                    "locked": { "type": "github", "narHash": "sha256-version2" }
440                },
441                "root": { "inputs": { "nixpkgs": "nixpkgs" } }
442            },
443            "root": "root",
444            "version": 7
445        }"#;
446
447        let analysis1 = FlakeLockAnalyzer::from_json(json1).unwrap().analyze();
448        let analysis2 = FlakeLockAnalyzer::from_json(json2).unwrap().analyze();
449
450        assert_ne!(analysis1.locked_digest, analysis2.locked_digest);
451    }
452
453    #[test]
454    fn test_malformed_json_error() {
455        let result = FlakeLockAnalyzer::from_json("not valid json");
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn test_multiple_inputs_all_locked() {
461        let json = r#"{
462            "nodes": {
463                "nixpkgs": {
464                    "locked": { "type": "github", "narHash": "sha256-aaa" }
465                },
466                "crane": {
467                    "locked": { "type": "github", "narHash": "sha256-bbb" }
468                },
469                "flake-utils": {
470                    "locked": { "type": "github", "narHash": "sha256-ccc" }
471                },
472                "root": {
473                    "inputs": {
474                        "nixpkgs": "nixpkgs",
475                        "crane": "crane",
476                        "flake-utils": "flake-utils"
477                    }
478                }
479            },
480            "root": "root",
481            "version": 7
482        }"#;
483
484        let analyzer = FlakeLockAnalyzer::from_json(json).unwrap();
485        let analysis = analyzer.analyze();
486
487        assert!(analysis.is_pure);
488        assert!(analysis.locked_digest.starts_with("sha256:"));
489    }
490
491    #[test]
492    fn test_real_project_flake_lock() {
493        // Test with the actual project's flake.lock (if it exists)
494        let lock_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
495            .parent()
496            .unwrap()
497            .parent()
498            .unwrap()
499            .join("flake.lock");
500
501        if lock_path.exists() {
502            let analyzer = FlakeLockAnalyzer::from_path(&lock_path).unwrap();
503            let analysis = analyzer.analyze();
504
505            // The project's flake.lock should be fully locked
506            assert!(
507                analysis.is_pure,
508                "Project flake.lock has unlocked inputs: {:?}",
509                analysis.unlocked_inputs
510            );
511            assert!(analysis.locked_digest.starts_with("sha256:"));
512
513            // Verify the digest is deterministic
514            let analysis2 = FlakeLockAnalyzer::from_path(&lock_path).unwrap().analyze();
515            assert_eq!(analysis.locked_digest, analysis2.locked_digest);
516        }
517    }
518}