use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet, VecDeque};
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak};
use fusevm::Value;
pub const CONTENT_CAP: usize = 8192;
pub const MAX_OPS: usize = 256;
const SUMMARY_MAX: usize = 64;
static PROV_ACTIVE: AtomicBool = AtomicBool::new(false);
static CURRENT_LINE: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvOp {
pub op: String,
pub args: Vec<String>,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvNode {
pub origin: String,
pub origin_line: usize,
pub ops: Vec<ProvOp>,
pub owner: Option<String>,
pub dropped_ops: usize,
}
impl ProvNode {
fn origin(origin: impl Into<String>, line: usize) -> Self {
Self {
origin: origin.into(),
origin_line: line,
ops: Vec::new(),
owner: None,
dropped_ops: 0,
}
}
fn push_op(&mut self, op: ProvOp) {
if self.ops.last() == Some(&op) {
return;
}
if self.ops.len() >= MAX_OPS {
self.dropped_ops += 1;
return;
}
self.ops.push(op);
}
}
enum ValueWeak {
Str(Weak<String>),
Array(Weak<Vec<Value>>),
}
impl ValueWeak {
fn still_at(&self, ptr: usize) -> bool {
match self {
ValueWeak::Str(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
ValueWeak::Array(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
}
}
}
struct PtrEntry {
weak: ValueWeak,
node: ProvNode,
}
#[derive(Default)]
struct Ledger {
ptr: HashMap<usize, PtrEntry>,
name: HashMap<String, ProvNode>,
tracked: HashSet<String>,
content: HashMap<u64, ProvNode>,
content_order: VecDeque<u64>,
}
fn ledger() -> &'static Mutex<Ledger> {
static LEDGER: OnceLock<Mutex<Ledger>> = OnceLock::new();
LEDGER.get_or_init(|| Mutex::new(Ledger::default()))
}
fn lock() -> MutexGuard<'static, Ledger> {
match ledger().lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
pub fn enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
if std::env::var("ZSHRS_PROVENANCE").is_ok_and(|v| v == "0") {
return false;
}
crate::config::current().provenance.enabled
})
}
#[inline]
pub fn active() -> bool {
PROV_ACTIVE.load(Ordering::Relaxed)
}
#[inline]
pub fn note_line(line: usize) {
CURRENT_LINE.store(line, Ordering::Relaxed);
}
pub fn current_line() -> usize {
CURRENT_LINE.load(Ordering::Relaxed)
}
pub fn summarize_str(s: &str) -> String {
let mut out = String::with_capacity(SUMMARY_MAX + 12);
out.push('"');
for ch in s.chars().take(SUMMARY_MAX) {
match ch {
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c => out.push(c),
}
}
if s.chars().count() > SUMMARY_MAX {
out.push('…');
}
out.push('"');
out
}
pub fn summarize_value(v: &Value) -> String {
match v {
Value::Str(s) => summarize_str(s),
Value::Array(a) => format!("ARRAY len={}", a.len()),
Value::Hash(h) => format!("ASSOC entries={}", h.len()),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::Status(c) => format!("STATUS {}", c),
Value::Undef => "unset".to_string(),
other => format!("{:?}", other),
}
}
fn content_key(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
fn value_ptr(v: &Value) -> Option<usize> {
match v {
Value::Str(s) => Some(Arc::as_ptr(s) as usize),
Value::Array(a) => Some(Arc::as_ptr(a) as usize),
_ => None,
}
}
fn value_weak(v: &Value) -> Option<ValueWeak> {
match v {
Value::Str(s) => Some(ValueWeak::Str(Arc::downgrade(s))),
Value::Array(a) => Some(ValueWeak::Array(Arc::downgrade(a))),
_ => None,
}
}
impl Ledger {
fn ptr_node(&mut self, ptr: usize) -> Option<ProvNode> {
let live = match self.ptr.get(&ptr) {
Some(e) => e.weak.still_at(ptr),
None => return None,
};
if !live {
self.ptr.remove(&ptr);
return None;
}
self.ptr.get(&ptr).map(|e| e.node.clone())
}
fn put_ptr(&mut self, v: &Value, node: ProvNode) {
let (Some(ptr), Some(weak)) = (value_ptr(v), value_weak(v)) else {
return;
};
self.ptr.insert(ptr, PtrEntry { weak, node });
}
fn put_content(&mut self, s: &str, node: ProvNode) {
if s.is_empty() {
return;
}
let key = content_key(s);
if self.content.insert(key, node).is_none() {
self.content_order.push_back(key);
while self.content_order.len() > CONTENT_CAP {
if let Some(old) = self.content_order.pop_front() {
self.content.remove(&old);
}
}
}
}
fn content_node(&self, s: &str) -> Option<ProvNode> {
if s.is_empty() {
return None;
}
self.content.get(&content_key(s)).cloned()
}
fn extend_owner(&mut self, node: &ProvNode, op: &ProvOp) {
let Some(owner) = node.owner.as_deref() else {
return;
};
if let Some(target) = self.name.get_mut(owner) {
target.push_op(op.clone());
}
}
}
pub fn track_name(name: &str, current_value: Option<&str>) -> bool {
if !enabled() {
return false;
}
let line = current_line();
let mut l = lock();
l.tracked.insert(name.to_string());
let mut node = ProvNode::origin(
match current_value {
Some(v) => format!("param {} = {}", name, summarize_str(v)),
None => format!("param {} (unset)", name),
},
line,
);
node.owner = Some(name.to_string());
l.name.insert(name.to_string(), node);
drop(l);
PROV_ACTIVE.store(true, Ordering::Relaxed);
true
}
pub fn untrack_name(name: &str) -> bool {
let mut l = lock();
let had = l.tracked.remove(name) | l.name.remove(name).is_some();
let empty = l.tracked.is_empty() && l.name.is_empty();
drop(l);
if empty {
PROV_ACTIVE.store(false, Ordering::Relaxed);
}
had
}
pub fn clear() {
let mut l = lock();
*l = Ledger::default();
drop(l);
PROV_ACTIVE.store(false, Ordering::Relaxed);
}
pub fn tracked_names() -> Vec<String> {
let l = lock();
let mut v: Vec<String> = l.tracked.iter().cloned().collect();
v.sort();
v
}
pub fn lookup_name(name: &str) -> Option<ProvNode> {
lock().name.get(name).cloned()
}
pub fn lookup_value(v: &Value) -> Option<ProvNode> {
let ptr = value_ptr(v)?;
lock().ptr_node(ptr)
}
pub fn lookup_content(s: &str) -> Option<ProvNode> {
lock().content_node(s)
}
pub fn on_cmd_subst(source: &str, out: &str) {
let line = current_line();
let origin = if source.is_empty() {
"cmdsubst".to_string()
} else {
format!("cmdsubst {}", summarize_str(source))
};
lock().put_content(out, ProvNode::origin(origin, line));
}
pub fn on_process_subst(source: &str, out: &str) {
let line = current_line();
let origin = if source.is_empty() {
"procsubst".to_string()
} else {
format!("procsubst {}", summarize_str(source))
};
lock().put_content(out, ProvNode::origin(origin, line));
}
pub fn on_glob(pattern: &str, results: &[String]) {
if results.is_empty() || results.len() > 32 {
return;
}
let line = current_line();
let mut l = lock();
for r in results {
l.put_content(
r,
ProvNode::origin(format!("glob {}", summarize_str(pattern)), line),
);
}
}
pub fn on_heredoc(kind: &str, body: &str) {
let line = current_line();
lock().put_content(body, ProvNode::origin(kind.to_string(), line));
}
pub fn on_param_read(name: &str, value: &Value) {
let line = current_line();
let mut l = lock();
let Some(mut node) = l.name.get(name).cloned() else {
return;
};
let op = ProvOp {
op: "expand".to_string(),
args: vec![format!("${}", name), summarize_value(value)],
line,
};
if let Some(target) = l.name.get_mut(name) {
target.push_op(op.clone());
}
node.push_op(op);
node.owner = Some(name.to_string());
l.put_ptr(value, node.clone());
if let Value::Str(s) = value {
l.put_content(s, node);
}
}
pub fn on_concat(lhs: &Value, rhs: &Value, result: &Value) {
let line = current_line();
let mut l = lock();
let mut chosen: Option<ProvNode> = None;
for operand in [lhs, rhs] {
let found = value_ptr(operand)
.and_then(|p| l.ptr_node(p))
.or_else(|| match operand {
Value::Str(s) => l.content_node(s),
_ => None,
});
if let Some(node) = found {
if chosen.as_ref().is_none_or(|c| node.ops.len() > c.ops.len()) {
chosen = Some(node);
}
}
}
let Some(mut node) = chosen else {
return;
};
let is_noop = matches!(lhs, Value::Str(s) if s.is_empty())
|| matches!(rhs, Value::Str(s) if s.is_empty());
if !is_noop {
let op = ProvOp {
op: "concat".to_string(),
args: vec![summarize_value(lhs), summarize_value(rhs)],
line,
};
l.extend_owner(&node, &op);
node.push_op(op);
}
l.put_ptr(result, node.clone());
if let Value::Str(s) = result {
l.put_content(s, node);
}
}
pub fn on_param_write(name: &str, kind: &str, value: &str) {
let line = current_line();
let mut l = lock();
if !l.tracked.contains(name) {
return;
}
let inherited = l.content_node(value);
let mut node = match inherited {
Some(mut n) => {
n.owner = Some(name.to_string());
n
}
None => {
let mut n = ProvNode::origin(format!("{} {}", kind, summarize_str(value)), line);
n.owner = Some(name.to_string());
n
}
};
node.push_op(ProvOp {
op: kind.to_string(),
args: vec![name.to_string(), summarize_str(value)],
line,
});
l.name.insert(name.to_string(), node.clone());
l.put_content(value, node);
}
pub fn on_param_unset(name: &str) {
let line = current_line();
let mut l = lock();
if let Some(node) = l.name.get_mut(name) {
node.push_op(ProvOp {
op: "unset".to_string(),
args: vec![name.to_string()],
line,
});
}
}
pub fn on_exec(kind: &str, args: &[String]) {
if args.is_empty() {
return;
}
let line = current_line();
let cmd = args[0].clone();
let mut l = lock();
for (i, a) in args.iter().enumerate().skip(1) {
let Some(mut node) = l.content_node(a) else {
continue;
};
let op = ProvOp {
op: kind.to_string(),
args: vec![cmd.clone(), format!("argv[{}]", i)],
line,
};
l.extend_owner(&node, &op);
node.push_op(op);
l.put_content(a, node);
}
}
pub fn render(label: &str, node: &ProvNode) -> String {
let mut out = format!("{}\n", label);
out.push_str(&format!(
" origin: {} (line {})\n",
node.origin, node.origin_line
));
if node.ops.is_empty() {
out.push_str(" ops: (none)\n");
return out;
}
out.push_str(" ops:\n");
for (i, op) in node.ops.iter().enumerate() {
out.push_str(&format!(
" {:>2}. {:<10} {:<40} line {}\n",
i + 1,
op.op,
op.args.join(" "),
op.line
));
}
if node.dropped_ops > 0 {
out.push_str(&format!(
" … {} more ops (chain capped at {})\n",
node.dropped_ops, MAX_OPS
));
}
out
}
pub fn render_json(label: &str, node: &ProvNode) -> String {
let mut out = String::new();
out.push_str(&format!(
"{{\"name\":{:?},\"origin\":{:?},\"origin_line\":{},\"ops\":[",
label, node.origin, node.origin_line
));
for (i, op) in node.ops.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("{{\"op\":{:?},\"args\":[", op.op));
for (j, a) in op.args.iter().enumerate() {
if j > 0 {
out.push(',');
}
out.push_str(&format!("{:?}", a));
}
out.push_str(&format!("],\"line\":{}}}", op.line));
}
out.push_str(&format!("],\"dropped_ops\":{}}}", node.dropped_ops));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> std::sync::MutexGuard<'static, ()> {
let g = crate::test_util::global_state_lock();
clear();
PROV_ACTIVE.store(true, Ordering::Relaxed);
g
}
#[test]
fn tracked_name_seeds_an_origin_from_the_current_value() {
let _g = setup();
note_line(7);
assert!(track_name("FOO", Some("bar")));
let node = lookup_name("FOO").expect("tracked name has a node");
assert_eq!(node.origin_line, 7);
assert!(node.origin.contains("param FOO"), "origin = {}", node.origin);
assert!(node.ops.is_empty(), "no ops at the origin");
assert_eq!(tracked_names(), vec!["FOO".to_string()]);
}
#[test]
fn assignment_inherits_the_command_substitutions_lineage() {
let _g = setup();
note_line(3);
track_name("OUT", None);
on_cmd_subst("date +%s", "1750000000");
on_param_write("OUT", "assign", "1750000000");
let node = lookup_name("OUT").expect("assignment created a node");
assert!(
node.origin.starts_with("cmdsubst"),
"origin must come from the substitution, got {}",
node.origin
);
assert_eq!(node.ops.len(), 1);
assert_eq!(node.ops[0].op, "assign");
}
#[test]
fn expansion_then_exec_extends_the_owning_parameters_chain() {
let _g = setup();
note_line(1);
track_name("F", Some("report.txt"));
on_param_write("F", "assign", "report.txt");
let v = Value::str("report.txt");
on_param_read("F", &v);
on_exec("exec", &["wc".into(), "-l".into(), "report.txt".into()]);
let node = lookup_name("F").expect("F is tracked");
let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
assert!(
ops.contains(&"exec"),
"consumption must land on the owner's chain, got {:?}",
ops
);
let exec_op = node.ops.iter().find(|o| o.op == "exec").unwrap();
assert_eq!(exec_op.args[0], "wc");
assert_eq!(exec_op.args[1], "argv[2]");
}
#[test]
fn concat_carries_the_lineage_of_whichever_operand_has_one() {
let _g = setup();
note_line(4);
track_name("F", Some("alpha"));
on_param_write("F", "assign", "alpha");
let read = Value::str("alpha");
on_param_read("F", &read);
let joined = Value::str("alpha.bak");
on_concat(&read, &Value::str(".bak"), &joined);
let node = lookup_value(&joined).expect("result inherited the chain");
assert_eq!(node.owner.as_deref(), Some("F"));
assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("concat"));
let owner = lookup_name("F").unwrap();
assert!(owner.ops.iter().any(|o| o.op == "concat"), "{owner:?}");
}
#[test]
fn an_empty_segment_concat_propagates_without_recording_an_op() {
let _g = setup();
track_name("F", Some("alpha"));
on_param_write("F", "assign", "alpha");
let read = Value::str("alpha");
on_param_read("F", &read);
let before = lookup_name("F").unwrap().ops.len();
let same = Value::str("alpha");
on_concat(&Value::str(""), &read, &same);
assert_eq!(
lookup_name("F").unwrap().ops.len(),
before,
"a concat that adds no bytes must not appear in the chain"
);
assert!(
lookup_value(&same).is_some(),
"the lineage still has to reach the produced value"
);
}
#[test]
fn concat_of_two_untracked_values_records_nothing() {
let _g = setup();
let out = Value::str("ab");
on_concat(&Value::str("a"), &Value::str("b"), &out);
assert!(lookup_value(&out).is_none());
}
#[test]
fn untracked_names_never_gain_a_lineage() {
let _g = setup();
on_cmd_subst("ls", "a\nb");
on_param_write("UNTRACKED", "assign", "a\nb");
assert!(
lookup_name("UNTRACKED").is_none(),
"writes to untracked names must not create rows"
);
}
#[test]
fn value_lineage_survives_arc_clones_and_dies_with_the_value() {
let _g = setup();
track_name("V", Some("x"));
let v = Value::str("payload");
on_param_read("V", &v);
let clone = v.clone();
assert!(
lookup_value(&clone).is_some(),
"an Arc clone is the same value"
);
let ptr = value_ptr(&v).unwrap();
drop(v);
drop(clone);
let mut l = lock();
assert!(l.ptr_node(ptr).is_none(), "dropped value must reap its row");
assert!(!l.ptr.contains_key(&ptr), "reaped row must be removed");
}
#[test]
fn content_entries_are_bounded_by_the_fifo_cap() {
let _g = setup();
for i in 0..(CONTENT_CAP + 100) {
on_cmd_subst("gen", &format!("value-{}", i));
}
let l = lock();
assert_eq!(l.content.len(), CONTENT_CAP);
assert_eq!(l.content_order.len(), CONTENT_CAP);
drop(l);
assert!(
lookup_content("value-0").is_none(),
"oldest speculative entry must have been evicted"
);
assert!(
lookup_content(&format!("value-{}", CONTENT_CAP + 99)).is_some(),
"newest speculative entry must survive"
);
}
#[test]
fn high_fanout_globs_are_not_recorded() {
let _g = setup();
let many: Vec<String> = (0..64).map(|i| format!("f{}", i)).collect();
on_glob("*", &many);
assert!(
lookup_content("f0").is_none(),
"a 64-match glob must not evict the ledger"
);
on_glob("*.rs", &["lib.rs".to_string()]);
assert!(lookup_content("lib.rs").is_some(), "small globs record");
}
#[test]
fn untrack_and_clear_disarm_the_engine() {
let _g = setup();
track_name("A", Some("1"));
track_name("B", Some("2"));
assert!(untrack_name("A"));
assert!(active(), "still armed while B is tracked");
assert!(untrack_name("B"));
assert!(!active(), "last untrack disarms the hot gate");
assert!(!untrack_name("B"), "second untrack is a no-op");
track_name("C", None);
clear();
assert!(!active());
assert!(tracked_names().is_empty());
}
#[test]
fn unset_is_recorded_as_the_final_op() {
let _g = setup();
track_name("Z", Some("v"));
on_param_unset("Z");
let node = lookup_name("Z").unwrap();
assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("unset"));
}
#[test]
fn render_and_json_carry_the_whole_chain() {
let _g = setup();
note_line(12);
track_name("R", None);
on_cmd_subst("echo hi", "hi");
on_param_write("R", "assign", "hi");
let node = lookup_name("R").unwrap();
let text = render("R", &node);
assert!(text.contains("origin: cmdsubst"), "text = {}", text);
assert!(text.contains("line 12"), "text = {}", text);
let json = render_json("R", &node);
assert!(json.starts_with("{\"name\":\"R\""), "json = {}", json);
assert!(json.contains("\"op\":\"assign\""), "json = {}", json);
assert!(json.ends_with("\"dropped_ops\":0}"), "json = {}", json);
}
#[test]
fn concurrent_hooks_keep_the_ledger_coherent() {
let _g = setup();
note_line(1);
track_name("SHARED", Some("seed"));
let handles: Vec<_> = (0..8)
.map(|i| {
std::thread::spawn(move || {
for n in 0..50 {
let out = format!("t{}-{}", i, n);
on_cmd_subst("worker", &out);
let v = Value::str(out.clone());
on_param_read("SHARED", &v);
on_exec("exec", &["cmd".into(), out]);
assert!(lookup_name("SHARED").is_some());
}
})
})
.collect();
for h in handles {
h.join().expect("thread");
}
let node = lookup_name("SHARED").expect("origin still live");
assert_eq!(node.origin_line, 1);
assert_eq!(node.owner.as_deref(), Some("SHARED"));
}
}