Skip to main content

active_core/
provenance.rs

1//! Hermetic provenance, content hashing, and lockfiles (C-050/C-051,
2//! spec/reproducibility.md). Pure collection over the parsed module,
3//! an environment snapshot, and the filesystem (hashes only):
4//! evaluation itself performs no I/O.
5
6use crate::ir::{Module, Node, NodeId};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, HashSet};
9use std::path::Path;
10
11/// Lockfile format version.
12pub const LOCK_VERSION: u32 = 1;
13
14/// One environment input declared by the document.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct EnvInput {
17    pub name: String,
18    pub set: bool,
19    /// Plain reads record the value verbatim; secret-bound reads
20    /// record nothing here (the secret invariant wins).
21    pub value: Option<String>,
22    /// True when a `Secret` node directly locates this variable
23    /// (`secret(env.X)` and `secret.env("X")` share that shape).
24    pub secret: bool,
25    /// Drift signal for secret-bound reads (preimage-resistant).
26    pub value_hash: Option<String>,
27}
28
29/// One file input: stamped path plus content hash.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct FileInput {
32    pub path: String,
33    pub sha256: String,
34}
35
36/// Provenance for one evaluation: environment, includes, main file,
37/// and the effective profile.
38#[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
46/// Names bound for secrecy: every environment variable reachable
47/// backward through the dependency graph from any `Secret` node.
48/// Direct locators (`secret(env.X)`, `secret.env("X")`) are the
49/// common case; transitive flows (`let h = env.H` +
50/// `secret(h)`, interpolations) count too — a variable that feeds
51/// a secret anywhere is secret everywhere (the conservative
52/// direction: plain uses of the same variable withhold the value
53/// as well, documented in spec §2).
54fn 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
84/// Canonical identity for path comparison: exact, then
85/// case-insensitive, then filesystem-canonicalized (8.3
86/// short-names on Windows). Unresolvable paths fall back to string
87/// equality.
88fn 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
108/// SHA-256 hex of a file's contents, or an error naming the file
109/// (never its contents — paths are safe to quote, data is not).
110fn 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
116/// Collect provenance for an evaluated module: every `Env` node in
117/// the arena (declared inputs — the audit-safe superset of
118/// attempted reads), every stamped include file, the main file, and
119/// the effective profile. `env` is the same snapshot evaluation
120/// used; `main_file` is the parsed path when file-based.
121pub 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    // Distinct stamped files minus the main file (canonical
162    // identity — stamps may differ textually from the CLI path).
163    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
208/// Content hash over the canonical redacted form: compact JSON with
209/// sorted keys, secrets already replaced by `***SECRET***`
210/// (`value_to_json`). Same tree → same `sha256:<hex>` on every
211/// platform. Raw secret values never enter the hash.
212pub 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/// A complete lockfile record: hash, toolchain, and provenance.
219#[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    /// Render the `active-lock.toml` document (spec §4).
243    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}