Skip to main content

cuenv_ci/flake/
lock.rs

1//! Data structures for Nix flake.lock v7 format
2//!
3//! This module provides serde-compatible types for parsing flake.lock files.
4//! The flake.lock format is JSON and contains a graph of locked flake inputs.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Nix flake.lock file representation (version 7)
10///
11/// The flake.lock file contains a directed graph of flake inputs,
12/// where each input can reference other inputs or follow paths.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct FlakeLock {
15    /// Version of the lockfile format (currently 7)
16    pub version: u8,
17
18    /// Root node identifier (usually "root")
19    pub root: String,
20
21    /// All flake input nodes indexed by name
22    pub nodes: HashMap<String, FlakeNode>,
23}
24
25/// A node in the flake dependency graph
26///
27/// This is a unified representation that can be either a root node
28/// (which just contains input references) or an input node
29/// (which contains locked version information).
30///
31/// The distinction is made by checking if `locked` or `original` is present.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct FlakeNode {
34    /// Whether this is a non-flake input (file, tarball, etc.)
35    /// Only present on input nodes.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub flake: Option<bool>,
38
39    /// Locked (pinned) version information
40    /// Only present on input nodes (None for root nodes)
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub locked: Option<LockedInfo>,
43
44    /// Original input specification (before locking)
45    /// Only present on input nodes (None for root nodes)
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub original: Option<OriginalInfo>,
48
49    /// Transitive inputs - present on both root and input nodes
50    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
51    pub inputs: HashMap<String, InputRef>,
52}
53
54impl FlakeNode {
55    /// Check if this is a root node (no locked or original info)
56    #[must_use]
57    pub const fn is_root(&self) -> bool {
58        self.locked.is_none() && self.original.is_none() && self.flake.is_none()
59    }
60
61    /// Check if this is an input node (has locked or original info)
62    #[must_use]
63    pub const fn is_input(&self) -> bool {
64        self.locked.is_some() || self.original.is_some() || self.flake.is_some()
65    }
66}
67
68/// Reference to another input node
69///
70/// Can be either a direct reference to a node name,
71/// or a "follows" path to inherit from another input.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(untagged)]
74pub enum InputRef {
75    /// Direct reference to another node by name
76    Direct(String),
77    /// Follows path - inherits input from another node
78    /// e.g., `["nixpkgs"]` means follow root's nixpkgs
79    Follows(Vec<String>),
80}
81
82/// Locked (pinned) version information for an input
83///
84/// This contains the exact version that was resolved when
85/// `nix flake lock` was run. The `nar_hash` is the critical
86/// field for ensuring reproducibility.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct LockedInfo {
89    /// Input type (github, gitlab, tarball, path, etc.)
90    #[serde(rename = "type")]
91    pub locked_type: String,
92
93    /// Timestamp of last modification (Unix epoch)
94    #[serde(rename = "lastModified")]
95    pub last_modified: Option<i64>,
96
97    /// Content hash - critical for purity verification
98    /// Format: "sha256-<base64>" or similar
99    #[serde(rename = "narHash")]
100    pub nar_hash: Option<String>,
101
102    /// Git revision hash (for github/gitlab/git types)
103    pub rev: Option<String>,
104
105    /// Repository owner (for github/gitlab types)
106    pub owner: Option<String>,
107
108    /// Repository name
109    pub repo: Option<String>,
110
111    /// URL (for tarball/url types)
112    pub url: Option<String>,
113
114    /// Revision count (for some tarball sources)
115    #[serde(rename = "revCount")]
116    pub rev_count: Option<i64>,
117}
118
119/// Original (unpinned) input specification
120///
121/// This represents how the input was specified in flake.nix
122/// before being locked to a specific version.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124pub struct OriginalInfo {
125    /// Input type (github, gitlab, tarball, path, etc.)
126    #[serde(rename = "type")]
127    pub original_type: String,
128
129    /// Branch reference (e.g., "nixos-unstable")
130    /// Presence of ref without rev in locked indicates unpinned
131    #[serde(rename = "ref")]
132    pub reference: Option<String>,
133
134    /// Repository owner
135    pub owner: Option<String>,
136
137    /// Repository name
138    pub repo: Option<String>,
139
140    /// URL (for tarball/url types)
141    pub url: Option<String>,
142}
143
144impl FlakeLock {
145    /// Parse a flake.lock from JSON string
146    ///
147    /// # Errors
148    /// Returns an error if the JSON is invalid or doesn't match the schema
149    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
150        serde_json::from_str(json)
151    }
152
153    /// Get the root node
154    #[must_use]
155    pub fn root_node(&self) -> Option<&FlakeNode> {
156        self.nodes.get(&self.root).filter(|node| node.is_root())
157    }
158
159    /// Get an input node by name
160    #[must_use]
161    pub fn get_input(&self, name: &str) -> Option<&FlakeNode> {
162        self.nodes.get(name).filter(|node| node.is_input())
163    }
164
165    /// Get any node by name (root or input)
166    #[must_use]
167    pub fn get_node(&self, name: &str) -> Option<&FlakeNode> {
168        self.nodes.get(name)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_parse_minimal_flake_lock() {
178        let json = r#"{
179            "nodes": {
180                "root": { "inputs": {} }
181            },
182            "root": "root",
183            "version": 7
184        }"#;
185
186        let lock = FlakeLock::from_json(json).unwrap();
187        assert_eq!(lock.version, 7);
188        assert_eq!(lock.root, "root");
189        assert!(lock.root_node().is_some());
190    }
191
192    #[test]
193    fn test_parse_with_locked_input() {
194        let json = r#"{
195            "nodes": {
196                "nixpkgs": {
197                    "locked": {
198                        "type": "github",
199                        "owner": "NixOS",
200                        "repo": "nixpkgs",
201                        "rev": "abc123",
202                        "narHash": "sha256-xxxxxxxxxxxxx"
203                    },
204                    "original": {
205                        "type": "github",
206                        "owner": "NixOS",
207                        "repo": "nixpkgs"
208                    }
209                },
210                "root": {
211                    "inputs": { "nixpkgs": "nixpkgs" }
212                }
213            },
214            "root": "root",
215            "version": 7
216        }"#;
217
218        let lock = FlakeLock::from_json(json).unwrap();
219        let input = lock.get_input("nixpkgs").unwrap();
220        assert!(input.locked.is_some());
221        let locked = input.locked.as_ref().unwrap();
222        assert_eq!(locked.locked_type, "github");
223        assert_eq!(locked.nar_hash.as_deref(), Some("sha256-xxxxxxxxxxxxx"));
224    }
225
226    #[test]
227    fn test_parse_follows_reference() {
228        let json = r#"{
229            "nodes": {
230                "nixpkgs": {
231                    "locked": {
232                        "type": "github",
233                        "narHash": "sha256-abc"
234                    }
235                },
236                "rust-overlay": {
237                    "inputs": {
238                        "nixpkgs": ["nixpkgs"]
239                    },
240                    "locked": {
241                        "type": "github",
242                        "narHash": "sha256-def"
243                    }
244                },
245                "root": {
246                    "inputs": {
247                        "nixpkgs": "nixpkgs",
248                        "rust-overlay": "rust-overlay"
249                    }
250                }
251            },
252            "root": "root",
253            "version": 7
254        }"#;
255
256        let lock = FlakeLock::from_json(json).unwrap();
257        let rust_overlay = lock.get_input("rust-overlay").unwrap();
258
259        // Check that rust-overlay follows nixpkgs
260        let nixpkgs_ref = rust_overlay.inputs.get("nixpkgs").unwrap();
261        assert!(matches!(nixpkgs_ref, InputRef::Follows(path) if path == &["nixpkgs"]));
262    }
263
264    #[test]
265    fn test_parse_non_flake_input() {
266        let json = r#"{
267            "nodes": {
268                "advisory-db": {
269                    "flake": false,
270                    "locked": {
271                        "type": "github",
272                        "narHash": "sha256-xyz"
273                    }
274                },
275                "root": {
276                    "inputs": { "advisory-db": "advisory-db" }
277                }
278            },
279            "root": "root",
280            "version": 7
281        }"#;
282
283        let lock = FlakeLock::from_json(json).unwrap();
284        let input = lock.get_input("advisory-db").unwrap();
285        assert_eq!(input.flake, Some(false));
286    }
287}