1use crate::ir::{Module, Node, NodeId};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, HashSet};
9use std::path::Path;
10
11pub const LOCK_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct EnvInput {
17 pub name: String,
18 pub set: bool,
19 pub value: Option<String>,
22 pub secret: bool,
25 pub value_hash: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct FileInput {
32 pub path: String,
33 pub sha256: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Provenance {
40 pub env: Vec<EnvInput>,
41 pub includes: Vec<FileInput>,
42 pub main: Option<FileInput>,
43 pub profile: Option<String>,
44}
45
46fn secret_bound_names(module: &Module) -> HashSet<String> {
55 use petgraph::Direction;
56 let mut names = HashSet::new();
57 let mut visited = HashSet::new();
58 let mut stack = Vec::new();
59 for (index, node) in module.arena.iter().enumerate() {
60 if matches!(node, Node::Secret { .. }) {
61 let id = NodeId(index as u32);
62 if visited.insert(id) {
63 stack.push(id);
64 }
65 }
66 }
67 while let Some(id) = stack.pop() {
68 if let Some(Node::Env { name, .. }) = module.get(id) {
69 names.insert(name.clone());
70 }
71 let Some(&index) = module.node_to_graph.get(&id) else {
72 continue;
73 };
74 for neighbor in module.graph.neighbors_directed(index, Direction::Incoming) {
75 let dep = module.graph[neighbor];
76 if visited.insert(dep) {
77 stack.push(dep);
78 }
79 }
80 }
81 names
82}
83
84fn same_file(a: &str, b: &str) -> bool {
89 if a == b || a.eq_ignore_ascii_case(b) {
90 return true;
91 }
92 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
93 (Ok(x), Ok(y)) => x == y,
94 _ => false,
95 }
96}
97
98fn sha256_hex(bytes: &[u8]) -> String {
99 let mut hasher = Sha256::new();
100 hasher.update(bytes);
101 hasher
102 .finalize()
103 .iter()
104 .map(|b| format!("{b:02x}"))
105 .collect()
106}
107
108fn hash_file(path: &str) -> Result<String, String> {
111 let bytes = std::fs::read(path)
112 .map_err(|e| format!("cannot read include file '{path}' for hashing: {e}"))?;
113 Ok(sha256_hex(&bytes))
114}
115
116pub fn collect_provenance(
122 module: &Module,
123 env: &std::collections::HashMap<String, String>,
124 main_file: Option<&Path>,
125 profile: Option<&str>,
126) -> Result<Provenance, String> {
127 let secret = secret_bound_names(module);
128 let mut names = BTreeMap::new();
129 for node in &module.arena {
130 if let Node::Env { name, .. } = node {
131 names.insert(name.clone(), ());
132 }
133 }
134 let mut env_inputs = Vec::new();
135 for (name, ()) in names {
136 let is_secret = secret.contains(&name);
137 match env.get(&name) {
138 Some(value) if !is_secret => env_inputs.push(EnvInput {
139 name: name.clone(),
140 set: true,
141 value: Some(value.clone()),
142 secret: false,
143 value_hash: None,
144 }),
145 Some(value) => env_inputs.push(EnvInput {
146 name: name.clone(),
147 set: true,
148 value: None,
149 secret: true,
150 value_hash: Some(format!("sha256:{}", sha256_hex(value.as_bytes()))),
151 }),
152 None => env_inputs.push(EnvInput {
153 name: name.clone(),
154 set: false,
155 value: None,
156 secret: is_secret,
157 value_hash: None,
158 }),
159 }
160 }
161 let main_canon = main_file.and_then(|p| std::fs::canonicalize(p).ok());
164 let main_label = main_file.map(|p| p.to_string_lossy().into_owned());
165 let mut seen: HashSet<String> = HashSet::new();
166 let mut includes = Vec::new();
167 let mut stamps: Vec<String> = module.node_files.values().cloned().collect();
168 stamps.sort();
169 for stamped in stamps {
170 if !seen.insert(stamped.clone()) {
171 continue;
172 }
173 let is_main = match (&main_canon, &main_label) {
174 (Some(canon), _) => std::fs::canonicalize(&stamped)
175 .map(|c| c == *canon)
176 .unwrap_or_else(|_| same_file(&stamped, &canon.to_string_lossy())),
177 (None, Some(label)) => same_file(&stamped, label),
178 (None, None) => false,
179 };
180 if is_main {
181 continue;
182 }
183 includes.push(FileInput {
184 path: stamped.clone(),
185 sha256: hash_file(&stamped)?,
186 });
187 }
188 let main = match main_file {
189 Some(path) => {
190 let label = path.to_string_lossy().into_owned();
191 Some(FileInput {
192 path: label.clone(),
193 sha256: std::fs::read(&label)
194 .map(|bytes| sha256_hex(&bytes))
195 .map_err(|e| format!("cannot read main file '{label}' for hashing: {e}"))?,
196 })
197 }
198 None => None,
199 };
200 Ok(Provenance {
201 env: env_inputs,
202 includes,
203 main,
204 profile: profile.map(str::to_string),
205 })
206}
207
208pub fn content_hash(value: &crate::ir::Value) -> String {
213 let canonical = serde_json::to_string(&crate::convert::value_to_json(value))
214 .unwrap_or_else(|_| "null".to_string());
215 format!("sha256:{}", sha256_hex(canonical.as_bytes()))
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct LockManifest {
221 pub toolchain: String,
222 pub content_hash: String,
223 pub profile: Option<String>,
224 pub main: Option<FileInput>,
225 pub env: Vec<EnvInput>,
226 pub includes: Vec<FileInput>,
227}
228
229impl LockManifest {
230 pub fn new(toolchain: String, value: &crate::ir::Value, provenance: Provenance) -> Self {
231 let profile = provenance.profile.clone();
232 Self {
233 toolchain,
234 content_hash: content_hash(value),
235 profile,
236 main: provenance.main,
237 env: provenance.env,
238 includes: provenance.includes,
239 }
240 }
241
242 pub fn render(&self) -> String {
244 let mut doc = toml::map::Map::new();
245 doc.insert(
246 "version".to_string(),
247 toml::Value::Integer(LOCK_VERSION as i64),
248 );
249 doc.insert(
250 "toolchain".to_string(),
251 toml::Value::String(self.toolchain.clone()),
252 );
253 doc.insert(
254 "content_hash".to_string(),
255 toml::Value::String(self.content_hash.clone()),
256 );
257 if let Some(profile) = &self.profile {
258 doc.insert("profile".to_string(), toml::Value::String(profile.clone()));
259 }
260 if let Some(main) = &self.main {
261 let mut table = toml::map::Map::new();
262 table.insert("path".to_string(), toml::Value::String(main.path.clone()));
263 table.insert(
264 "sha256".to_string(),
265 toml::Value::String(main.sha256.clone()),
266 );
267 doc.insert("main".to_string(), toml::Value::Table(table));
268 }
269 let mut provenance = toml::map::Map::new();
270 if !self.env.is_empty() {
271 let mut array = Vec::new();
272 for input in &self.env {
273 let mut entry = toml::map::Map::new();
274 entry.insert("name".to_string(), toml::Value::String(input.name.clone()));
275 entry.insert("set".to_string(), toml::Value::Boolean(input.set));
276 if let Some(value) = &input.value {
277 entry.insert("value".to_string(), toml::Value::String(value.clone()));
278 }
279 if input.secret {
280 entry.insert("secret".to_string(), toml::Value::Boolean(true));
281 }
282 if let Some(hash) = &input.value_hash {
283 entry.insert("value_hash".to_string(), toml::Value::String(hash.clone()));
284 }
285 array.push(toml::Value::Table(entry));
286 }
287 provenance.insert("env".to_string(), toml::Value::Array(array));
288 }
289 if !self.includes.is_empty() {
290 let mut array = Vec::new();
291 for include in &self.includes {
292 let mut entry = toml::map::Map::new();
293 entry.insert(
294 "path".to_string(),
295 toml::Value::String(include.path.clone()),
296 );
297 entry.insert(
298 "sha256".to_string(),
299 toml::Value::String(include.sha256.clone()),
300 );
301 array.push(toml::Value::Table(entry));
302 }
303 provenance.insert("includes".to_string(), toml::Value::Array(array));
304 }
305 if !provenance.is_empty() {
306 doc.insert("provenance".to_string(), toml::Value::Table(provenance));
307 }
308 let header = "# Active lockfile v1 — generated by `active lock`. Do not edit.\n";
309 format!(
310 "{header}{}",
311 toml::to_string(&toml::Value::Table(doc)).unwrap_or_default()
312 )
313 }
314}