1use super::error::FlakeLockError;
7use super::lock::{FlakeLock, FlakeNode, InputRef};
8use sha2::{Digest, Sha256};
9use std::collections::HashSet;
10use std::path::Path;
11
12#[derive(Debug, Clone)]
14pub struct PurityAnalysis {
15 pub is_pure: bool,
17
18 pub unlocked_inputs: Vec<UnlockedInput>,
20
21 pub locked_digest: String,
24}
25
26#[derive(Debug, Clone)]
28pub struct UnlockedInput {
29 pub name: String,
31
32 pub reason: UnlockReason,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum UnlockReason {
39 MissingLockedSection,
41
42 MissingNarHash,
44
45 FollowsUnlocked {
47 target: String,
49 },
50
51 UnpinnedReference {
53 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
71pub struct FlakeLockAnalyzer {
73 lock: FlakeLock,
74}
75
76impl FlakeLockAnalyzer {
77 #[must_use]
79 pub const fn new(lock: FlakeLock) -> Self {
80 Self { lock }
81 }
82
83 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 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 #[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 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 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 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 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 if input.is_input() {
161 self.check_input_node(name, input, unlocked, hashes, checked);
162 }
163 }
164 }
165 InputRef::Follows(path) => {
166 if let Some(target_name) = path.first()
169 && let Some(target) = self.lock.nodes.get(target_name)
170 {
171 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 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 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 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 if let Some(original) = &input.original
216 && original.reference.is_some()
217 && locked.rev.is_none()
218 {
219 }
222
223 hashes.push(nar_hash.clone());
225
226 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 fn compute_locked_digest(input_hashes: &[String]) -> String {
235 let mut sha_hasher = Sha256::new();
236
237 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]); }
245
246 format!("sha256:{}", hex::encode(sha_hasher.finalize()))
247 }
248
249 #[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 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 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 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 let analysis2 = FlakeLockAnalyzer::from_path(&lock_path).unwrap().analyze();
515 assert_eq!(analysis.locked_digest, analysis2.locked_digest);
516 }
517 }
518}