Skip to main content

nix_env_manager/
flake.rs

1//! Nix Flake hashing and metadata
2//!
3//! Provides functions to generate content-addressable hashes from Nix Flakes,
4//! ensuring environment reproducibility.
5
6use crate::error::NixError;
7use crate::Result;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11use std::path::Path;
12use std::process::Command;
13use tracing::{debug, info, warn};
14
15/// Nix environment hash - content-addressable identifier
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct NixHash {
18    /// The SHA256 hash
19    pub hash: String,
20    /// Source of the hash (flake.lock, flake.nix, or metadata)
21    pub source: HashSource,
22}
23
24/// Source of the Nix hash
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum HashSource {
27    /// Hash computed from flake.lock file
28    FlakeLock,
29    /// Hash computed from flake.nix file (fallback)
30    FlakeNix,
31    /// Hash from nix flake metadata command
32    Metadata,
33    /// Hash from directory contents (no flake)
34    Directory,
35}
36
37impl std::fmt::Display for NixHash {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "{}", self.hash)
40    }
41}
42
43impl NixHash {
44    /// Create a new NixHash
45    pub fn new(hash: String, source: HashSource) -> Self {
46        NixHash { hash, source }
47    }
48
49    /// Get short hash (first 12 characters)
50    pub fn short(&self) -> String {
51        self.hash.chars().take(12).collect()
52    }
53}
54
55/// Metadata from a Nix Flake
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct FlakeMetadata {
58    /// Flake description
59    pub description: Option<String>,
60    /// Last modified timestamp
61    #[serde(rename = "lastModified")]
62    pub last_modified: Option<u64>,
63    /// Locked inputs
64    pub locks: Option<FlakeLocks>,
65    /// Original flake URL
66    pub original_url: Option<String>,
67    /// Resolved URL
68    pub resolved_url: Option<String>,
69    /// Revision (if from git)
70    pub revision: Option<String>,
71}
72
73/// Flake lock file structure
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct FlakeLocks {
76    /// Lock file version
77    pub version: u32,
78    /// Root node name
79    pub root: String,
80    /// Nodes in the lock file
81    pub nodes: HashMap<String, FlakeLockNode>,
82}
83
84/// A node in the flake.lock file
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct FlakeLockNode {
87    /// Inputs this node depends on
88    pub inputs: Option<HashMap<String, serde_json::Value>>,
89    /// Locked reference
90    pub locked: Option<LockedRef>,
91    /// Original reference
92    pub original: Option<serde_json::Value>,
93}
94
95/// A locked reference in flake.lock
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct LockedRef {
98    /// Owner (for GitHub)
99    pub owner: Option<String>,
100    /// Repository name
101    pub repo: Option<String>,
102    /// Git revision
103    pub rev: Option<String>,
104    /// Reference type (github, git, path, etc.)
105    #[serde(rename = "type")]
106    pub ref_type: Option<String>,
107    /// NAR hash
108    #[serde(rename = "narHash")]
109    pub nar_hash: Option<String>,
110    /// Last modified timestamp
111    #[serde(rename = "lastModified")]
112    pub last_modified: Option<u64>,
113}
114
115/// Generate environment hash from a Nix Flake
116///
117/// This function attempts to generate a reproducible hash in the following order:
118/// 1. Parse and hash the flake.lock file (most reliable)
119/// 2. Run `nix flake metadata --json` and hash the output
120/// 3. Hash the flake.nix file directly (fallback)
121/// 4. Hash the directory contents (last resort)
122///
123/// # TDD: test_changing_flake_input_changes_hash
124pub fn generate_environment_hash(flake_path: &Path) -> Result<NixHash> {
125    info!("Generating environment hash for {:?}", flake_path);
126
127    // Strategy 1: Use flake.lock (most reliable for reproducibility)
128    let lock_path = flake_path.join("flake.lock");
129    if lock_path.exists() {
130        debug!("Found flake.lock, using for hash");
131        return hash_flake_lock(&lock_path);
132    }
133
134    // Strategy 2: Use nix flake metadata
135    let flake_nix = flake_path.join("flake.nix");
136    if flake_nix.exists() {
137        debug!("No flake.lock, trying nix flake metadata");
138        if let Ok(hash) = hash_from_nix_metadata(flake_path) {
139            return Ok(hash);
140        }
141
142        // Strategy 3: Hash flake.nix directly
143        warn!("nix flake metadata failed, hashing flake.nix directly");
144        return hash_flake_nix(&flake_nix);
145    }
146
147    // Strategy 4: Hash directory (no flake found)
148    warn!("No flake found, hashing directory contents");
149    hash_directory(flake_path)
150}
151
152/// Hash the flake.lock file
153fn hash_flake_lock(lock_path: &Path) -> Result<NixHash> {
154    let content = std::fs::read(lock_path)?;
155
156    // Parse to validate and normalize
157    let locks: FlakeLocks =
158        serde_json::from_slice(&content).map_err(|e| NixError::InvalidFlakeLock(e.to_string()))?;
159
160    // Re-serialize for consistent hashing (handles formatting differences)
161    let normalized = serde_json::to_vec(&locks)?;
162
163    let mut hasher = Sha256::new();
164    hasher.update(&normalized);
165    let hash = hex::encode(hasher.finalize());
166
167    debug!("Flake.lock hash: {}", &hash[..12]);
168    Ok(NixHash::new(hash, HashSource::FlakeLock))
169}
170
171/// Hash using nix flake metadata command
172fn hash_from_nix_metadata(flake_path: &Path) -> Result<NixHash> {
173    let output = Command::new("nix")
174        .args(["flake", "metadata", "--json", "--no-update-lock-file"])
175        .current_dir(flake_path)
176        .output()?;
177
178    if !output.status.success() {
179        let stderr = String::from_utf8_lossy(&output.stderr);
180        return Err(NixError::NixCommandFailed(stderr.to_string()));
181    }
182
183    let mut hasher = Sha256::new();
184    hasher.update(&output.stdout);
185    let hash = hex::encode(hasher.finalize());
186
187    debug!("Metadata hash: {}", &hash[..12]);
188    Ok(NixHash::new(hash, HashSource::Metadata))
189}
190
191/// Hash the flake.nix file directly
192fn hash_flake_nix(flake_nix: &Path) -> Result<NixHash> {
193    let content = std::fs::read(flake_nix)?;
194
195    let mut hasher = Sha256::new();
196    hasher.update(&content);
197    let hash = hex::encode(hasher.finalize());
198
199    debug!("Flake.nix hash: {}", &hash[..12]);
200    Ok(NixHash::new(hash, HashSource::FlakeNix))
201}
202
203/// Hash directory contents (fallback for non-flake directories)
204fn hash_directory(dir: &Path) -> Result<NixHash> {
205    let mut hasher = Sha256::new();
206    hash_directory_recursive(dir, &mut hasher)?;
207    let hash = hex::encode(hasher.finalize());
208
209    debug!("Directory hash: {}", &hash[..12]);
210    Ok(NixHash::new(hash, HashSource::Directory))
211}
212
213/// Recursively hash directory contents
214fn hash_directory_recursive(dir: &Path, hasher: &mut Sha256) -> Result<()> {
215    if !dir.is_dir() {
216        return Ok(());
217    }
218
219    let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
220
221    // Sort for deterministic ordering
222    entries.sort_by_key(|e| e.path());
223
224    for entry in entries {
225        let path = entry.path();
226        let name = path.file_name().unwrap_or_default().to_string_lossy();
227
228        // Skip hidden files and common non-source directories
229        if name.starts_with('.') || name == "target" || name == "node_modules" {
230            continue;
231        }
232
233        // Hash the relative path with separator
234        hasher.update(name.as_bytes());
235        hasher.update(b"\0");
236
237        if path.is_file() {
238            let content = std::fs::read(&path)?;
239            hasher.update(&content);
240            hasher.update(b"\0");
241        } else if path.is_dir() {
242            hash_directory_recursive(&path, hasher)?;
243        }
244    }
245
246    Ok(())
247}
248
249/// Get full flake metadata using nix command
250pub fn get_flake_metadata(flake_path: &Path) -> Result<FlakeMetadata> {
251    let output = Command::new("nix")
252        .args(["flake", "metadata", "--json", "--no-update-lock-file"])
253        .current_dir(flake_path)
254        .output()?;
255
256    if !output.status.success() {
257        let stderr = String::from_utf8_lossy(&output.stderr);
258        return Err(NixError::NixCommandFailed(stderr.to_string()));
259    }
260
261    let metadata: FlakeMetadata = serde_json::from_slice(&output.stdout)?;
262    Ok(metadata)
263}
264
265/// Lock flake inputs (equivalent to `nix flake lock`)
266#[allow(dead_code)]
267pub fn lock_flake(flake_path: &Path) -> Result<()> {
268    let output = Command::new("nix")
269        .args(["flake", "lock"])
270        .current_dir(flake_path)
271        .output()?;
272
273    if !output.status.success() {
274        let stderr = String::from_utf8_lossy(&output.stderr);
275        return Err(NixError::NixCommandFailed(stderr.to_string()));
276    }
277
278    Ok(())
279}
280
281/// Update flake inputs (equivalent to `nix flake update`)
282#[allow(dead_code)]
283pub fn update_flake(flake_path: &Path) -> Result<NixHash> {
284    let output = Command::new("nix")
285        .args(["flake", "update"])
286        .current_dir(flake_path)
287        .output()?;
288
289    if !output.status.success() {
290        let stderr = String::from_utf8_lossy(&output.stderr);
291        return Err(NixError::NixCommandFailed(stderr.to_string()));
292    }
293
294    // Return new hash after update
295    generate_environment_hash(flake_path)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use tempfile::tempdir;
302
303    #[test]
304    fn test_nix_hash_display() {
305        let hash = NixHash::new("abc123def456".to_string(), HashSource::FlakeLock);
306        assert_eq!(format!("{}", hash), "abc123def456");
307    }
308
309    #[test]
310    fn test_nix_hash_short() {
311        let hash = NixHash::new(
312            "abc123def456789012345678901234567890123456789012345678901234".to_string(),
313            HashSource::FlakeLock,
314        );
315        assert_eq!(hash.short(), "abc123def456");
316    }
317
318    #[test]
319    fn test_hash_flake_lock() {
320        let dir = tempdir().unwrap();
321        let lock_path = dir.path().join("flake.lock");
322
323        let lock_content = r#"{
324            "version": 7,
325            "root": "root",
326            "nodes": {
327                "root": {
328                    "inputs": {}
329                },
330                "nixpkgs": {
331                    "locked": {
332                        "type": "github",
333                        "owner": "NixOS",
334                        "repo": "nixpkgs",
335                        "rev": "abc123"
336                    }
337                }
338            }
339        }"#;
340
341        std::fs::write(&lock_path, lock_content).unwrap();
342
343        let hash = hash_flake_lock(&lock_path).unwrap();
344        assert!(!hash.hash.is_empty());
345        assert_eq!(hash.source, HashSource::FlakeLock);
346    }
347
348    #[test]
349    fn test_changing_flake_input_changes_hash() {
350        let dir = tempdir().unwrap();
351        let lock_path = dir.path().join("flake.lock");
352
353        // First version
354        let lock_v1 = r#"{"version": 7, "root": "root", "nodes": {"root": {"inputs": {}}, "nixpkgs": {"locked": {"rev": "v1"}}}}"#;
355        std::fs::write(&lock_path, lock_v1).unwrap();
356        let hash1 = hash_flake_lock(&lock_path).unwrap();
357
358        // Second version (different rev)
359        let lock_v2 = r#"{"version": 7, "root": "root", "nodes": {"root": {"inputs": {}}, "nixpkgs": {"locked": {"rev": "v2"}}}}"#;
360        std::fs::write(&lock_path, lock_v2).unwrap();
361        let hash2 = hash_flake_lock(&lock_path).unwrap();
362
363        assert_ne!(
364            hash1.hash, hash2.hash,
365            "Different inputs should produce different hashes"
366        );
367    }
368
369    #[test]
370    fn test_hash_flake_nix() {
371        let dir = tempdir().unwrap();
372        let flake_nix = dir.path().join("flake.nix");
373
374        std::fs::write(&flake_nix, r#"{ outputs = { self }: {}; }"#).unwrap();
375
376        let hash = hash_flake_nix(&flake_nix).unwrap();
377        assert!(!hash.hash.is_empty());
378        assert_eq!(hash.source, HashSource::FlakeNix);
379    }
380
381    #[test]
382    fn test_hash_directory() {
383        let dir = tempdir().unwrap();
384
385        // Create some files
386        std::fs::write(dir.path().join("file1.txt"), "content1").unwrap();
387        std::fs::write(dir.path().join("file2.txt"), "content2").unwrap();
388
389        let hash = hash_directory(dir.path()).unwrap();
390        assert!(!hash.hash.is_empty());
391        assert_eq!(hash.source, HashSource::Directory);
392    }
393
394    #[test]
395    fn test_hash_directory_deterministic() {
396        let dir = tempdir().unwrap();
397
398        std::fs::write(dir.path().join("a.txt"), "aaa").unwrap();
399        std::fs::write(dir.path().join("b.txt"), "bbb").unwrap();
400
401        let hash1 = hash_directory(dir.path()).unwrap();
402        let hash2 = hash_directory(dir.path()).unwrap();
403
404        assert_eq!(hash1.hash, hash2.hash);
405    }
406
407    #[test]
408    fn test_generate_environment_hash_prefers_lock() {
409        let dir = tempdir().unwrap();
410
411        // Create both flake.nix and flake.lock
412        std::fs::write(dir.path().join("flake.nix"), "{ }").unwrap();
413        std::fs::write(
414            dir.path().join("flake.lock"),
415            r#"{"version": 7, "root": "root", "nodes": {"root": {}}}"#,
416        )
417        .unwrap();
418
419        let hash = generate_environment_hash(dir.path()).unwrap();
420        assert_eq!(
421            hash.source,
422            HashSource::FlakeLock,
423            "Should prefer flake.lock when available"
424        );
425    }
426}