1use chrono::{Local, NaiveDate, SecondsFormat, TimeZone};
60use std::collections::hash_map::DefaultHasher;
61use std::collections::{HashMap, HashSet, VecDeque};
62use std::hash::{Hash, Hasher};
63use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
64use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak};
65use std::time::{SystemTime, UNIX_EPOCH};
66
67use fusevm::Value;
68
69pub const CONTENT_CAP: usize = 8192;
75
76pub const MAX_OPS: usize = 256;
80
81pub const MAX_AUTO_NAMES: usize = 4096;
86
87const VOLATILE: &[&str] = &[
93 "_",
94 "?",
95 "!",
96 "$",
97 "#",
98 "COLUMNS",
99 "EPOCHREALTIME",
100 "EPOCHSECONDS",
101 "HISTCMD",
102 "LINENO",
103 "LINES",
104 "RANDOM",
105 "SECONDS",
106 "pipestatus",
107 "status",
108];
109
110const SUMMARY_MAX: usize = 64;
115
116static PROV_ACTIVE: AtomicBool = AtomicBool::new(false);
120
121static TRACK_ALL: AtomicBool = AtomicBool::new(false);
125
126static CURRENT_LINE: AtomicUsize = AtomicUsize::new(0);
131
132#[derive(Debug, Clone, PartialEq, Eq, Default)]
136pub struct Site {
137 pub line: usize,
139 pub file: Option<String>,
144 pub func: Option<String>,
148 pub time_ms: i64,
150}
151
152impl Site {
153 fn now() -> Self {
156 let frame = current_function();
163 let (func, file, line) = match frame {
164 Some((name, file, flineno)) => {
165 (Some(name), file, current_line() + flineno.max(0) as usize)
166 }
167 None => (
168 None,
169 crate::ported::utils::scriptfilename_get(),
170 current_line(),
171 ),
172 };
173 Self {
174 line,
175 file: file.filter(|f| f != "zsh"),
180 func,
181 time_ms: SystemTime::now()
182 .duration_since(UNIX_EPOCH)
183 .map(|d| d.as_millis() as i64)
184 .unwrap_or(0),
185 }
186 }
187
188 fn same_position(&self, other: &Self) -> bool {
192 self.line == other.line && self.file == other.file && self.func == other.func
193 }
194
195 pub fn location(&self) -> String {
198 let base = match &self.file {
199 Some(f) => format!("{}:{}", f, self.line),
200 None => format!("line {}", self.line),
201 };
202 match &self.func {
203 Some(fun) => format!("{} ({})", base, fun),
204 None => base,
205 }
206 }
207
208 pub fn clock(&self, with_date: bool) -> String {
212 let fmt = if with_date {
213 "%Y-%m-%d %H:%M:%S%.3f"
214 } else {
215 "%H:%M:%S%.3f"
216 };
217 match Local.timestamp_millis_opt(self.time_ms).single() {
218 Some(dt) => dt.format(fmt).to_string(),
219 None => String::new(),
220 }
221 }
222
223 pub fn rfc3339(&self) -> String {
225 match Local.timestamp_millis_opt(self.time_ms).single() {
226 Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, false),
227 None => String::new(),
228 }
229 }
230
231 fn day(&self) -> Option<NaiveDate> {
233 Local
234 .timestamp_millis_opt(self.time_ms)
235 .single()
236 .map(|dt| dt.date_naive())
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct ProvOp {
243 pub op: String,
246 pub args: Vec<String>,
248 pub site: Site,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct ProvNode {
255 pub origin: String,
258 pub origin_site: Site,
260 pub ops: Vec<ProvOp>,
263 pub owner: Option<String>,
268 pub dropped_ops: usize,
270}
271
272impl ProvNode {
273 fn origin(origin: impl Into<String>, site: Site) -> Self {
275 Self {
276 origin: origin.into(),
277 origin_site: site,
278 ops: Vec::new(),
279 owner: None,
280 dropped_ops: 0,
281 }
282 }
283
284 fn push_op(&mut self, op: ProvOp) {
288 if self
289 .ops
290 .last()
291 .is_some_and(|l| l.op == op.op && l.args == op.args && l.site.same_position(&op.site))
292 {
293 return;
294 }
295 if self.ops.len() >= MAX_OPS {
296 self.dropped_ops += 1;
297 return;
298 }
299 self.ops.push(op);
300 }
301}
302
303enum ValueWeak {
307 Str(Weak<String>),
309 Array(Weak<Vec<Value>>),
311}
312
313impl ValueWeak {
314 fn still_at(&self, ptr: usize) -> bool {
320 match self {
321 ValueWeak::Str(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
322 ValueWeak::Array(w) => w.upgrade().is_some_and(|a| Arc::as_ptr(&a) as usize == ptr),
323 }
324 }
325}
326
327struct PtrEntry {
330 weak: ValueWeak,
332 node: ProvNode,
334}
335
336#[derive(Default)]
340struct Ledger {
341 ptr: HashMap<usize, PtrEntry>,
343 name: HashMap<String, ProvNode>,
345 tracked: HashSet<String>,
348 func: HashMap<String, ProvNode>,
352 tracked_funcs: HashSet<String>,
354 auto_dropped: usize,
357 content: HashMap<u64, ProvNode>,
360 content_order: VecDeque<u64>,
362}
363
364fn ledger() -> &'static Mutex<Ledger> {
365 static LEDGER: OnceLock<Mutex<Ledger>> = OnceLock::new();
366 LEDGER.get_or_init(|| Mutex::new(Ledger::default()))
367}
368
369fn lock() -> MutexGuard<'static, Ledger> {
373 match ledger().lock() {
374 Ok(g) => g,
375 Err(poisoned) => poisoned.into_inner(),
376 }
377}
378
379pub fn enabled() -> bool {
386 static ENABLED: OnceLock<bool> = OnceLock::new();
387 *ENABLED.get_or_init(|| {
388 if std::env::var("ZSHRS_PROVENANCE").is_ok_and(|v| v == "0") {
389 return false;
390 }
391 crate::config::current().provenance.enabled
392 })
393}
394
395fn track_all_configured() -> bool {
399 static CONFIGURED: OnceLock<bool> = OnceLock::new();
400 *CONFIGURED.get_or_init(|| match std::env::var("ZSHRS_PROVENANCE_ALL") {
401 Ok(v) if v == "1" => true,
402 Ok(v) if v == "0" => false,
403 _ => crate::config::current().provenance.track_all,
404 })
405}
406
407pub fn init_from_config() {
411 if enabled() && track_all_configured() {
412 set_track_all(true);
413 }
414}
415
416#[inline]
418pub fn track_all() -> bool {
419 TRACK_ALL.load(Ordering::Relaxed)
420}
421
422pub fn set_track_all(on: bool) -> bool {
426 if on && !enabled() {
427 return false;
428 }
429 TRACK_ALL.store(on, Ordering::Relaxed);
430 if on {
431 PROV_ACTIVE.store(true, Ordering::Relaxed);
432 } else {
433 let l = lock();
434 let empty = l.tracked.is_empty()
435 && l.name.is_empty()
436 && l.tracked_funcs.is_empty()
437 && l.func.is_empty();
438 drop(l);
439 if empty {
440 PROV_ACTIVE.store(false, Ordering::Relaxed);
441 }
442 }
443 true
444}
445
446fn auto_armable(name: &str) -> bool {
449 !name.is_empty() && !VOLATILE.contains(&name) && !name.chars().all(|c| c.is_ascii_digit())
450}
451
452#[inline]
455pub fn active() -> bool {
456 PROV_ACTIVE.load(Ordering::Relaxed)
457}
458
459#[inline]
464pub fn note_line(line: usize) {
465 CURRENT_LINE.store(line, Ordering::Relaxed);
466}
467
468pub fn current_line() -> usize {
470 CURRENT_LINE.load(Ordering::Relaxed)
471}
472
473fn current_function() -> Option<(String, Option<String>, i64)> {
486 let stack = crate::ported::modules::parameter::FUNCSTACK
487 .try_lock()
488 .ok()?;
489 let frame = stack.last()?;
490 (frame.tp == crate::ported::zsh_h::FS_FUNC)
491 .then(|| (frame.name.clone(), frame.filename.clone(), frame.flineno))
492}
493
494pub fn summarize_str(s: &str) -> String {
500 let mut out = String::with_capacity(SUMMARY_MAX + 12);
501 out.push('"');
502 for ch in s.chars().take(SUMMARY_MAX) {
503 match ch {
504 '\n' => out.push_str("\\n"),
505 '\t' => out.push_str("\\t"),
506 '"' => out.push_str("\\\""),
507 '\\' => out.push_str("\\\\"),
508 c => out.push(c),
509 }
510 }
511 if s.chars().count() > SUMMARY_MAX {
512 out.push('…');
513 }
514 out.push('"');
515 out
516}
517
518pub fn summarize_value(v: &Value) -> String {
520 match v {
521 Value::Str(s) => summarize_str(s),
522 Value::Array(a) => format!("ARRAY len={}", a.len()),
523 Value::Hash(h) => format!("ASSOC entries={}", h.len()),
524 Value::Int(n) => n.to_string(),
525 Value::Float(f) => f.to_string(),
526 Value::Bool(b) => b.to_string(),
527 Value::Status(c) => format!("STATUS {}", c),
528 Value::Undef => "unset".to_string(),
529 other => format!("{:?}", other),
530 }
531}
532
533fn content_key(s: &str) -> u64 {
534 let mut h = DefaultHasher::new();
535 s.hash(&mut h);
536 h.finish()
537}
538
539fn value_ptr(v: &Value) -> Option<usize> {
543 match v {
544 Value::Str(s) => Some(Arc::as_ptr(s) as usize),
545 Value::Array(a) => Some(Arc::as_ptr(a) as usize),
546 _ => None,
547 }
548}
549
550fn value_weak(v: &Value) -> Option<ValueWeak> {
551 match v {
552 Value::Str(s) => Some(ValueWeak::Str(Arc::downgrade(s))),
553 Value::Array(a) => Some(ValueWeak::Array(Arc::downgrade(a))),
554 _ => None,
555 }
556}
557
558impl Ledger {
561 fn ptr_node(&mut self, ptr: usize) -> Option<ProvNode> {
564 let live = match self.ptr.get(&ptr) {
565 Some(e) => e.weak.still_at(ptr),
566 None => return None,
567 };
568 if !live {
569 self.ptr.remove(&ptr);
570 return None;
571 }
572 self.ptr.get(&ptr).map(|e| e.node.clone())
573 }
574
575 fn put_ptr(&mut self, v: &Value, node: ProvNode) {
576 let (Some(ptr), Some(weak)) = (value_ptr(v), value_weak(v)) else {
577 return;
578 };
579 self.ptr.insert(ptr, PtrEntry { weak, node });
580 }
581
582 fn put_content(&mut self, s: &str, node: ProvNode) {
583 if s.is_empty() {
587 return;
588 }
589 let key = content_key(s);
590 if self.content.insert(key, node).is_none() {
591 self.content_order.push_back(key);
592 while self.content_order.len() > CONTENT_CAP {
593 if let Some(old) = self.content_order.pop_front() {
594 self.content.remove(&old);
595 }
596 }
597 }
598 }
599
600 fn content_node(&self, s: &str) -> Option<ProvNode> {
601 if s.is_empty() {
602 return None;
603 }
604 self.content.get(&content_key(s)).cloned()
605 }
606
607 fn extend_owner(&mut self, node: &ProvNode, op: &ProvOp) {
612 let Some(owner) = node.owner.as_deref() else {
613 return;
614 };
615 if let Some(target) = self.name.get_mut(owner) {
616 target.push_op(op.clone());
617 }
618 }
619}
620
621pub fn track_name(name: &str, current_value: Option<&str>) -> bool {
628 if !enabled() {
629 return false;
630 }
631 let site = Site::now();
632 let mut l = lock();
633 l.tracked.insert(name.to_string());
634 if let Some(v) = current_value {
639 let mut node = ProvNode::origin(format!("param {} = {}", name, summarize_str(v)), site);
640 node.owner = Some(name.to_string());
641 l.name.insert(name.to_string(), node);
642 }
643 drop(l);
644 PROV_ACTIVE.store(true, Ordering::Relaxed);
645 true
646}
647
648pub fn untrack_name(name: &str) -> bool {
651 let mut l = lock();
652 let had = l.tracked.remove(name) | l.name.remove(name).is_some();
653 let empty = l.tracked.is_empty()
654 && l.name.is_empty()
655 && l.tracked_funcs.is_empty()
656 && l.func.is_empty();
657 drop(l);
658 if empty && !track_all() {
662 PROV_ACTIVE.store(false, Ordering::Relaxed);
663 }
664 had
665}
666
667pub fn clear() {
670 let mut l = lock();
671 *l = Ledger::default();
672 drop(l);
673 TRACK_ALL.store(false, Ordering::Relaxed);
674 PROV_ACTIVE.store(false, Ordering::Relaxed);
675}
676
677pub fn tracked_names() -> Vec<String> {
679 let l = lock();
680 let mut v: Vec<String> = l.tracked.iter().cloned().collect();
681 v.sort();
682 v
683}
684
685pub fn lookup_name(name: &str) -> Option<ProvNode> {
687 lock().name.get(name).cloned()
688}
689
690pub fn lookup_value(v: &Value) -> Option<ProvNode> {
693 let ptr = value_ptr(v)?;
694 lock().ptr_node(ptr)
695}
696
697pub fn lookup_content(s: &str) -> Option<ProvNode> {
699 lock().content_node(s)
700}
701
702pub fn on_cmd_subst(source: &str, out: &str) {
711 let site = Site::now();
712 let origin = if source.is_empty() {
713 "cmdsubst".to_string()
714 } else {
715 format!("cmdsubst {}", summarize_str(source))
716 };
717 lock().put_content(out, ProvNode::origin(origin, site));
718}
719
720pub fn on_process_subst(source: &str, out: &str) {
723 let site = Site::now();
724 let origin = if source.is_empty() {
725 "procsubst".to_string()
726 } else {
727 format!("procsubst {}", summarize_str(source))
728 };
729 lock().put_content(out, ProvNode::origin(origin, site));
730}
731
732pub fn on_glob(pattern: &str, results: &[String]) {
736 if results.is_empty() || results.len() > 32 {
737 return;
738 }
739 let site = Site::now();
740 let mut l = lock();
741 for r in results {
742 l.put_content(
743 r,
744 ProvNode::origin(format!("glob {}", summarize_str(pattern)), site.clone()),
745 );
746 }
747}
748
749pub fn on_heredoc(kind: &str, body: &str) {
751 let site = Site::now();
752 lock().put_content(body, ProvNode::origin(kind.to_string(), site));
753}
754
755pub fn on_param_read(name: &str, value: &Value) {
760 let site = Site::now();
761 let mut l = lock();
762 let Some(mut node) = l.name.get(name).cloned() else {
763 return;
764 };
765 let op = ProvOp {
766 op: "expand".to_string(),
767 args: vec![format!("${}", name), summarize_value(value)],
768 site,
769 };
770 if let Some(target) = l.name.get_mut(name) {
771 target.push_op(op.clone());
772 }
773 node.push_op(op);
774 node.owner = Some(name.to_string());
775 l.put_ptr(value, node.clone());
776 if let Value::Str(s) = value {
777 l.put_content(s, node);
778 }
779}
780
781pub fn on_concat(lhs: &Value, rhs: &Value, result: &Value) {
788 let site = Site::now();
789 let mut l = lock();
790 let mut chosen: Option<ProvNode> = None;
791 for operand in [lhs, rhs] {
792 let found = value_ptr(operand)
793 .and_then(|p| l.ptr_node(p))
794 .or_else(|| match operand {
795 Value::Str(s) => l.content_node(s),
796 _ => None,
797 });
798 if let Some(node) = found {
799 if chosen.as_ref().is_none_or(|c| node.ops.len() > c.ops.len()) {
802 chosen = Some(node);
803 }
804 }
805 }
806 let Some(mut node) = chosen else {
807 return;
808 };
809 let is_noop = matches!(lhs, Value::Str(s) if s.is_empty())
813 || matches!(rhs, Value::Str(s) if s.is_empty());
814 if !is_noop {
815 let op = ProvOp {
816 op: "concat".to_string(),
817 args: vec![summarize_value(lhs), summarize_value(rhs)],
818 site,
819 };
820 l.extend_owner(&node, &op);
821 node.push_op(op);
822 }
823 l.put_ptr(result, node.clone());
824 if let Value::Str(s) = result {
825 l.put_content(s, node);
826 }
827}
828
829pub fn on_param_write(name: &str, kind: &str, value: &str) {
839 let site = Site::now();
840 let mut l = lock();
841 if !l.tracked.contains(name) {
842 if !track_all() || !auto_armable(name) {
846 return;
847 }
848 if l.tracked.len() + l.tracked_funcs.len() >= MAX_AUTO_NAMES {
849 l.auto_dropped += 1;
850 return;
851 }
852 l.tracked.insert(name.to_string());
853 }
854 let inherited = l
858 .content_node(value)
859 .filter(|n| n.owner.as_deref() != Some(name));
860 let mut node = match l.name.remove(name) {
861 Some(mut existing) => {
866 if let Some(inh) = inherited {
867 existing.push_op(ProvOp {
868 op: "origin".to_string(),
869 args: vec![inh.origin],
870 site: inh.origin_site,
871 });
872 for op in inh.ops {
873 existing.push_op(op);
874 }
875 }
876 existing
877 }
878 None => inherited.unwrap_or_else(|| {
881 ProvNode::origin(format!("{} {}", kind, summarize_str(value)), site.clone())
882 }),
883 };
884 node.owner = Some(name.to_string());
885 node.push_op(ProvOp {
886 op: kind.to_string(),
887 args: vec![name.to_string(), summarize_str(value)],
888 site,
889 });
890 l.name.insert(name.to_string(), node.clone());
891 l.put_content(value, node);
892}
893
894pub fn on_param_unset(name: &str) {
897 let site = Site::now();
898 let mut l = lock();
899 if let Some(node) = l.name.get_mut(name) {
900 node.push_op(ProvOp {
901 op: "unset".to_string(),
902 args: vec![name.to_string()],
903 site,
904 });
905 }
906}
907
908pub fn on_exec(kind: &str, args: &[String]) {
913 if args.is_empty() {
914 return;
915 }
916 let site = Site::now();
917 let cmd = args[0].clone();
918 let mut l = lock();
919 for (i, a) in args.iter().enumerate().skip(1) {
920 let Some(mut node) = l.content_node(a) else {
921 continue;
922 };
923 let op = ProvOp {
924 op: kind.to_string(),
925 args: vec![cmd.clone(), format!("argv[{}]", i)],
926 site: site.clone(),
927 };
928 l.extend_owner(&node, &op);
929 node.push_op(op);
930 l.put_content(a, node);
931 }
932}
933
934fn def_site(file: Option<&str>, line: i64) -> Site {
946 let mut site = Site::now();
947 if file.is_some() || line > 0 {
948 site.file = file.map(str::to_string).filter(|f| f != "zsh");
949 site.line = line.max(0) as usize;
950 site.func = None;
951 }
952 site
953}
954
955pub fn track_func(name: &str, body: Option<&str>, file: Option<&str>, line: i64) -> bool {
959 if !enabled() {
960 return false;
961 }
962 let mut l = lock();
963 l.tracked_funcs.insert(name.to_string());
964 if file.is_some() || line > 0 {
979 let site = def_site(file, line);
980 let summary = body_summary(body);
986 l.func.entry(name.to_string()).or_insert_with(|| {
987 ProvNode::origin(
988 match &summary {
989 Some(b) => format!("function {} {{ {} }}", name, b),
990 None => format!("function {}", name),
991 },
992 site,
993 )
994 });
995 }
996 drop(l);
997 PROV_ACTIVE.store(true, Ordering::Relaxed);
998 true
999}
1000
1001pub fn untrack_func(name: &str) -> bool {
1003 let mut l = lock();
1004 let had = l.tracked_funcs.remove(name) | l.func.remove(name).is_some();
1005 let empty = l.tracked.is_empty()
1006 && l.name.is_empty()
1007 && l.tracked_funcs.is_empty()
1008 && l.func.is_empty();
1009 drop(l);
1010 if empty && !track_all() {
1011 PROV_ACTIVE.store(false, Ordering::Relaxed);
1012 }
1013 had
1014}
1015
1016pub fn lookup_func(name: &str) -> Option<ProvNode> {
1018 lock().func.get(name).cloned()
1019}
1020
1021pub fn tracked_func_names() -> Vec<String> {
1023 let l = lock();
1024 let mut v: Vec<String> = l.tracked_funcs.iter().cloned().collect();
1025 v.sort();
1026 v
1027}
1028
1029pub fn auto_dropped() -> usize {
1032 lock().auto_dropped
1033}
1034
1035fn auto_arm_func(l: &mut Ledger, name: &str) -> bool {
1038 if l.tracked_funcs.contains(name) {
1039 return true;
1040 }
1041 if !track_all() || !auto_armable(name) {
1042 return false;
1043 }
1044 if l.tracked.len() + l.tracked_funcs.len() >= MAX_AUTO_NAMES {
1045 l.auto_dropped += 1;
1046 return false;
1047 }
1048 l.tracked_funcs.insert(name.to_string());
1049 true
1050}
1051
1052fn body_summary(body: Option<&str>) -> Option<String> {
1059 let collapsed = body?.split_whitespace().collect::<Vec<_>>().join(" ");
1060 if collapsed.is_empty() {
1061 return None;
1062 }
1063 const MAX_BODY_WIDTH: usize = 32;
1066 if collapsed.chars().count() > MAX_BODY_WIDTH {
1067 let keep: String = collapsed.chars().take(MAX_BODY_WIDTH).collect();
1068 Some(format!("{}…", keep))
1069 } else {
1070 Some(collapsed)
1071 }
1072}
1073
1074pub fn on_func_define(name: &str, body: Option<&str>, file: Option<&str>, line: i64) {
1083 let site = def_site(file, line);
1084 let summary = body_summary(body);
1085 let mut l = lock();
1086 if !auto_arm_func(&mut l, name) {
1087 return;
1088 }
1089 match l.func.get_mut(name) {
1090 Some(node) => node.push_op(ProvOp {
1091 op: "redefine".to_string(),
1092 args: vec![match &summary {
1093 Some(b) => format!("{} {{ {} }}", name, b),
1094 None => name.to_string(),
1095 }],
1096 site,
1097 }),
1098 None => {
1099 l.func.insert(
1100 name.to_string(),
1101 ProvNode::origin(
1102 match &summary {
1103 Some(b) => format!("function {} {{ {} }}", name, b),
1104 None => format!("function {}", name),
1105 },
1106 site,
1107 ),
1108 );
1109 }
1110 }
1111}
1112
1113fn call_signature(name: &str, args: &[String]) -> String {
1119 const MAX_ARGS_WIDTH: usize = 32;
1122 let mut rendered = String::new();
1123 for (i, a) in args.iter().enumerate() {
1124 if i > 0 {
1125 rendered.push(' ');
1126 }
1127 if a.is_empty() || a.chars().any(char::is_whitespace) {
1128 rendered.push('\'');
1129 rendered.push_str(a);
1130 rendered.push('\'');
1131 } else {
1132 rendered.push_str(a);
1133 }
1134 if rendered.chars().count() > MAX_ARGS_WIDTH {
1135 let keep: String = rendered.chars().take(MAX_ARGS_WIDTH).collect();
1136 rendered = format!("{}…", keep);
1137 break;
1138 }
1139 }
1140 format!("{}({})", name, rendered)
1141}
1142
1143pub fn on_func_call(
1153 name: &str,
1154 args: &[String],
1155 body: Option<&str>,
1156 file: Option<&str>,
1157 line: i64,
1158) {
1159 let site = Site::now();
1160 let summary = body_summary(body);
1166 let mut l = lock();
1167 if !auto_arm_func(&mut l, name) {
1168 return;
1169 }
1170 let node = l.func.entry(name.to_string()).or_insert_with(|| {
1171 ProvNode::origin(
1172 match &summary {
1173 Some(b) => format!("function {} {{ {} }}", name, b),
1174 None => format!("function {}", name),
1175 },
1176 def_site(file, line),
1177 )
1178 });
1179 node.push_op(ProvOp {
1180 op: "call".to_string(),
1181 args: vec![call_signature(name, args)],
1182 site,
1183 });
1184}
1185
1186pub fn on_func_unset(name: &str) {
1189 let site = Site::now();
1190 let mut l = lock();
1191 if let Some(node) = l.func.get_mut(name) {
1192 node.push_op(ProvOp {
1193 op: "unfunction".to_string(),
1194 args: vec![name.to_string()],
1195 site,
1196 });
1197 }
1198}
1199
1200pub fn render(label: &str, node: &ProvNode) -> String {
1204 let mut out = format!("{}\n", label);
1205 out.push_str(&format!(
1206 " origin: {} ({}, {})\n",
1207 node.origin,
1208 node.origin_site.location(),
1209 node.origin_site.clock(true)
1210 ));
1211 if node.ops.is_empty() {
1212 out.push_str(" ops: (none)\n");
1213 return out;
1214 }
1215 let origin_day = node.origin_site.day();
1219 out.push_str(" ops:\n");
1220 for (i, op) in node.ops.iter().enumerate() {
1221 out.push_str(&format!(
1222 " {:>2}. {:<10} {:<40} {:<24} {}\n",
1223 i + 1,
1224 op.op,
1225 op.args.join(" "),
1226 op.site.location(),
1227 op.site.clock(op.site.day() != origin_day)
1228 ));
1229 }
1230 if node.dropped_ops > 0 {
1231 out.push_str(&format!(
1232 " … {} more ops (chain capped at {})\n",
1233 node.dropped_ops, MAX_OPS
1234 ));
1235 }
1236 out
1237}
1238
1239fn json_str_or_null(s: Option<&str>) -> String {
1241 match s {
1242 Some(v) => format!("{:?}", v),
1243 None => "null".to_string(),
1244 }
1245}
1246
1247pub fn render_json(label: &str, node: &ProvNode) -> String {
1251 let mut out = String::new();
1252 out.push_str(&format!(
1253 "{{\"name\":{:?},\"origin\":{:?},\"origin_line\":{},\"origin_file\":{},\"origin_function\":{},\"origin_time\":{:?},\"ops\":[",
1254 label,
1255 node.origin,
1256 node.origin_site.line,
1257 json_str_or_null(node.origin_site.file.as_deref()),
1258 json_str_or_null(node.origin_site.func.as_deref()),
1259 node.origin_site.rfc3339(),
1260 ));
1261 for (i, op) in node.ops.iter().enumerate() {
1262 if i > 0 {
1263 out.push(',');
1264 }
1265 out.push_str(&format!("{{\"op\":{:?},\"args\":[", op.op));
1266 for (j, a) in op.args.iter().enumerate() {
1267 if j > 0 {
1268 out.push(',');
1269 }
1270 out.push_str(&format!("{:?}", a));
1271 }
1272 out.push_str(&format!(
1273 "],\"line\":{},\"file\":{},\"function\":{},\"time\":{:?}}}",
1274 op.site.line,
1275 json_str_or_null(op.site.file.as_deref()),
1276 json_str_or_null(op.site.func.as_deref()),
1277 op.site.rfc3339(),
1278 ));
1279 }
1280 out.push_str(&format!("],\"dropped_ops\":{}}}", node.dropped_ops));
1281 out
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287
1288 fn setup() -> std::sync::MutexGuard<'static, ()> {
1291 let g = crate::test_util::global_state_lock();
1292 clear();
1293 PROV_ACTIVE.store(true, Ordering::Relaxed);
1294 g
1295 }
1296
1297 #[test]
1298 fn tracked_name_seeds_an_origin_from_the_current_value() {
1299 let _g = setup();
1300 note_line(7);
1301 assert!(track_name("FOO", Some("bar")));
1302 let node = lookup_name("FOO").expect("tracked name has a node");
1303 assert_eq!(node.origin_site.line, 7);
1304 assert!(
1305 node.origin.contains("param FOO"),
1306 "origin = {}",
1307 node.origin
1308 );
1309 assert!(node.ops.is_empty(), "no ops at the origin");
1310 assert_eq!(tracked_names(), vec!["FOO".to_string()]);
1311 }
1312
1313 #[test]
1314 fn assignment_inherits_the_command_substitutions_lineage() {
1315 let _g = setup();
1316 note_line(3);
1317 track_name("OUT", None);
1318 on_cmd_subst("date +%s", "1750000000");
1319 on_param_write("OUT", "assign", "1750000000");
1320 let node = lookup_name("OUT").expect("assignment created a node");
1321 assert!(
1322 node.origin.starts_with("cmdsubst"),
1323 "origin must come from the substitution, got {}",
1324 node.origin
1325 );
1326 assert_eq!(node.ops.len(), 1);
1327 assert_eq!(node.ops[0].op, "assign");
1328 }
1329
1330 #[test]
1331 fn expansion_then_exec_extends_the_owning_parameters_chain() {
1332 let _g = setup();
1333 note_line(1);
1334 track_name("F", Some("report.txt"));
1335 on_param_write("F", "assign", "report.txt");
1336 let v = Value::str("report.txt");
1338 on_param_read("F", &v);
1339 on_exec("exec", &["wc".into(), "-l".into(), "report.txt".into()]);
1340 let node = lookup_name("F").expect("F is tracked");
1341 let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1342 assert!(
1343 ops.contains(&"exec"),
1344 "consumption must land on the owner's chain, got {:?}",
1345 ops
1346 );
1347 let exec_op = node.ops.iter().find(|o| o.op == "exec").unwrap();
1348 assert_eq!(exec_op.args[0], "wc");
1349 assert_eq!(exec_op.args[1], "argv[2]");
1350 }
1351
1352 #[test]
1353 fn concat_carries_the_lineage_of_whichever_operand_has_one() {
1354 let _g = setup();
1355 note_line(4);
1356 track_name("F", Some("alpha"));
1357 on_param_write("F", "assign", "alpha");
1358 let read = Value::str("alpha");
1359 on_param_read("F", &read);
1360 let joined = Value::str("alpha.bak");
1361 on_concat(&read, &Value::str(".bak"), &joined);
1362 let node = lookup_value(&joined).expect("result inherited the chain");
1363 assert_eq!(node.owner.as_deref(), Some("F"));
1364 assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("concat"));
1365 let owner = lookup_name("F").unwrap();
1368 assert!(owner.ops.iter().any(|o| o.op == "concat"), "{owner:?}");
1369 }
1370
1371 #[test]
1372 fn an_empty_segment_concat_propagates_without_recording_an_op() {
1373 let _g = setup();
1374 track_name("F", Some("alpha"));
1375 on_param_write("F", "assign", "alpha");
1376 let read = Value::str("alpha");
1377 on_param_read("F", &read);
1378 let before = lookup_name("F").unwrap().ops.len();
1379 let same = Value::str("alpha");
1381 on_concat(&Value::str(""), &read, &same);
1382 assert_eq!(
1383 lookup_name("F").unwrap().ops.len(),
1384 before,
1385 "a concat that adds no bytes must not appear in the chain"
1386 );
1387 assert!(
1388 lookup_value(&same).is_some(),
1389 "the lineage still has to reach the produced value"
1390 );
1391 }
1392
1393 #[test]
1394 fn concat_of_two_untracked_values_records_nothing() {
1395 let _g = setup();
1396 let out = Value::str("ab");
1397 on_concat(&Value::str("a"), &Value::str("b"), &out);
1398 assert!(lookup_value(&out).is_none());
1399 }
1400
1401 #[test]
1402 fn untracked_names_never_gain_a_lineage() {
1403 let _g = setup();
1404 on_cmd_subst("ls", "a\nb");
1405 on_param_write("UNTRACKED", "assign", "a\nb");
1406 assert!(
1407 lookup_name("UNTRACKED").is_none(),
1408 "writes to untracked names must not create rows"
1409 );
1410 }
1411
1412 #[test]
1413 fn value_lineage_survives_arc_clones_and_dies_with_the_value() {
1414 let _g = setup();
1415 track_name("V", Some("x"));
1416 let v = Value::str("payload");
1417 on_param_read("V", &v);
1418 let clone = v.clone();
1419 assert!(
1420 lookup_value(&clone).is_some(),
1421 "an Arc clone is the same value"
1422 );
1423 let ptr = value_ptr(&v).unwrap();
1424 drop(v);
1425 drop(clone);
1426 let mut l = lock();
1429 assert!(l.ptr_node(ptr).is_none(), "dropped value must reap its row");
1430 assert!(!l.ptr.contains_key(&ptr), "reaped row must be removed");
1431 }
1432
1433 #[test]
1434 fn content_entries_are_bounded_by_the_fifo_cap() {
1435 let _g = setup();
1436 for i in 0..(CONTENT_CAP + 100) {
1437 on_cmd_subst("gen", &format!("value-{}", i));
1438 }
1439 let l = lock();
1440 assert_eq!(l.content.len(), CONTENT_CAP);
1441 assert_eq!(l.content_order.len(), CONTENT_CAP);
1442 drop(l);
1443 assert!(
1444 lookup_content("value-0").is_none(),
1445 "oldest speculative entry must have been evicted"
1446 );
1447 assert!(
1448 lookup_content(&format!("value-{}", CONTENT_CAP + 99)).is_some(),
1449 "newest speculative entry must survive"
1450 );
1451 }
1452
1453 #[test]
1454 fn high_fanout_globs_are_not_recorded() {
1455 let _g = setup();
1456 let many: Vec<String> = (0..64).map(|i| format!("f{}", i)).collect();
1457 on_glob("*", &many);
1458 assert!(
1459 lookup_content("f0").is_none(),
1460 "a 64-match glob must not evict the ledger"
1461 );
1462 on_glob("*.rs", &["lib.rs".to_string()]);
1463 assert!(lookup_content("lib.rs").is_some(), "small globs record");
1464 }
1465
1466 #[test]
1467 fn untrack_and_clear_disarm_the_engine() {
1468 let _g = setup();
1469 track_name("A", Some("1"));
1470 track_name("B", Some("2"));
1471 assert!(untrack_name("A"));
1472 assert!(active(), "still armed while B is tracked");
1473 assert!(untrack_name("B"));
1474 assert!(!active(), "last untrack disarms the hot gate");
1475 assert!(!untrack_name("B"), "second untrack is a no-op");
1476 track_name("C", None);
1477 clear();
1478 assert!(!active());
1479 assert!(tracked_names().is_empty());
1480 }
1481
1482 #[test]
1483 fn reassignment_extends_the_chain_instead_of_replacing_it() {
1484 let _g = setup();
1485 note_line(1);
1486 track_name("X", None);
1487 note_line(2);
1488 on_param_write("X", "assign", "23");
1489 note_line(3);
1490 on_param_write("X", "assign", "1");
1491 note_line(4);
1492 on_param_write("X", "assign", "55");
1493 let node = lookup_name("X").expect("X has a chain");
1494 assert_eq!(node.origin_site.line, 2, "origin is the first write");
1495 let seen: Vec<(&str, &str, usize)> = node
1496 .ops
1497 .iter()
1498 .map(|o| (o.op.as_str(), o.args[1].as_str(), o.site.line))
1499 .collect();
1500 assert_eq!(
1501 seen,
1502 vec![
1503 ("assign", "\"23\"", 2),
1504 ("assign", "\"1\"", 3),
1505 ("assign", "\"55\"", 4),
1506 ],
1507 "every assignment stays on the chain: {:?}",
1508 node.ops
1509 );
1510 }
1511
1512 #[test]
1513 fn a_later_substitution_is_spliced_in_not_swapped_for_the_chain() {
1514 let _g = setup();
1515 note_line(1);
1516 track_name("X", Some("seed"));
1517 note_line(2);
1518 on_param_write("X", "assign", "23");
1519 note_line(3);
1520 on_cmd_subst("date +%s", "1750000000");
1521 on_param_write("X", "assign", "1750000000");
1522 let node = lookup_name("X").expect("X has a chain");
1523 assert!(
1524 node.origin.contains("param X = "),
1525 "the armed value stays the origin, got {}",
1526 node.origin
1527 );
1528 let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1529 assert_eq!(ops, vec!["assign", "origin", "assign"], "{:?}", node.ops);
1530 assert!(
1531 node.ops[1].args[0].starts_with("cmdsubst"),
1532 "the substitution is recorded where it happened: {:?}",
1533 node.ops[1]
1534 );
1535 }
1536
1537 #[test]
1538 fn rewriting_a_parameter_with_its_own_value_does_not_duplicate_the_chain() {
1539 let _g = setup();
1540 track_name("X", None);
1541 on_param_write("X", "assign", "same");
1542 on_param_write("X", "assign", "same");
1543 let node = lookup_name("X").unwrap();
1544 assert_eq!(
1545 node.ops.len(),
1546 1,
1547 "immediate repeat collapses: {:?}",
1548 node.ops
1549 );
1550 note_line(9);
1551 on_param_write("X", "assign", "same");
1552 let node = lookup_name("X").unwrap();
1553 assert_eq!(
1554 node.ops.iter().filter(|o| o.op == "origin").count(),
1555 0,
1556 "a parameter never inherits from itself: {:?}",
1557 node.ops
1558 );
1559 }
1560
1561 #[test]
1562 fn a_repeat_at_the_same_position_collapses_despite_a_later_clock() {
1563 let _g = setup();
1564 note_line(4);
1565 track_name("Q", None);
1566 on_param_write("Q", "assign", "v");
1567 let first = lookup_name("Q").unwrap().ops[0].site.time_ms;
1568 std::thread::sleep(std::time::Duration::from_millis(5));
1569 on_param_write("Q", "assign", "v");
1570 let node = lookup_name("Q").unwrap();
1571 assert_eq!(node.ops.len(), 1, "the clock must not defeat the collapse");
1572 assert_eq!(node.ops[0].site.time_ms, first, "the first stamp stands");
1573 }
1574
1575 #[test]
1576 fn a_site_carries_the_file_the_line_belongs_to() {
1577 let _g = setup();
1578 let saved = crate::ported::utils::scriptfilename_get();
1579 crate::ported::utils::set_scriptfilename(Some("/tmp/lineage.zsh".to_string()));
1580 note_line(11);
1581 track_name("S", None);
1582 on_param_write("S", "assign", "v");
1583 crate::ported::utils::set_scriptfilename(saved);
1584 let node = lookup_name("S").unwrap();
1585 assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lineage.zsh"));
1586 assert_eq!(node.origin_site.line, 11);
1587 assert_eq!(node.origin_site.func, None, "no function frame is active");
1588 assert!(node.origin_site.time_ms > 0, "the origin is stamped");
1589 assert_eq!(node.origin_site.location(), "/tmp/lineage.zsh:11");
1590 assert!(
1591 node.origin_site.clock(true).starts_with("20"),
1592 "clock = {}",
1593 node.origin_site.clock(true)
1594 );
1595 assert!(
1596 render("S", &node).contains("/tmp/lineage.zsh:11"),
1597 "the report names the file"
1598 );
1599 }
1600
1601 #[test]
1602 fn track_all_arms_a_parameter_on_its_first_write() {
1603 let _g = setup();
1604 assert!(set_track_all(true));
1605 note_line(3);
1606 on_param_write("NEVER_ARMED", "assign", "v");
1607 let node = lookup_name("NEVER_ARMED").expect("track_all armed it");
1608 assert_eq!(node.ops.len(), 1);
1609 assert_eq!(tracked_names(), vec!["NEVER_ARMED".to_string()]);
1610 set_track_all(false);
1611 on_param_write("STILL_UNARMED", "assign", "v");
1612 assert!(
1613 lookup_name("STILL_UNARMED").is_none(),
1614 "turning it off stops arming new names"
1615 );
1616 }
1617
1618 #[test]
1624 fn call_ops_record_the_arguments_they_were_called_with() {
1625 let _g = setup();
1626 assert!(track_func("deploy", None, Some("/tmp/d.zsh"), 1));
1627 on_func_call(
1628 "deploy",
1629 &["staging".to_string()],
1630 None,
1631 Some("/tmp/d.zsh"),
1632 1,
1633 );
1634 on_func_call("deploy", &["prod".to_string()], None, Some("/tmp/d.zsh"), 2);
1635 on_func_call("deploy", &[], None, Some("/tmp/d.zsh"), 3);
1636 let node = lookup_func("deploy").expect("armed");
1637 let args: Vec<&str> = node.ops.iter().map(|o| o.args[0].as_str()).collect();
1638 assert_eq!(
1639 args,
1640 vec!["deploy(staging)", "deploy(prod)", "deploy()"],
1641 "each call must be distinguishable by its arguments"
1642 );
1643 }
1644
1645 #[test]
1649 fn call_signature_quotes_ambiguous_args_and_caps_long_lists() {
1650 let one = call_signature("f", &["a b".to_string()]);
1651 let two = call_signature("f", &["a".to_string(), "b".to_string()]);
1652 assert_eq!(one, "f('a b')");
1653 assert_eq!(two, "f(a b)");
1654 assert_ne!(one, two, "quoting is what keeps these apart");
1655 assert_eq!(call_signature("f", &["".to_string()]), "f('')");
1656
1657 let many: Vec<String> = (0..40).map(|i| format!("arg{i}")).collect();
1658 let rendered = call_signature("f", &many);
1659 assert!(rendered.ends_with("…)"), "long list truncates: {rendered}");
1660 assert!(
1661 rendered.chars().count() <= 40,
1662 "must fit render's 40-wide arg column: {} chars",
1663 rendered.chars().count()
1664 );
1665 }
1666
1667 #[test]
1668 fn track_all_skips_the_parameters_the_shell_rewrites_itself() {
1669 let _g = setup();
1670 assert!(set_track_all(true));
1671 for volatile in ["LINENO", "RANDOM", "status", "_", "3"] {
1672 on_param_write(volatile, "assign", "v");
1673 assert!(
1674 lookup_name(volatile).is_none(),
1675 "{volatile} must not arm itself"
1676 );
1677 }
1678 on_param_write("REPLY", "assign", "v");
1679 assert!(lookup_name("REPLY").is_some(), "ordinary names still arm");
1680 }
1681
1682 #[test]
1683 fn track_all_stops_arming_at_the_ceiling_and_counts_the_rest() {
1684 let _g = setup();
1685 assert!(set_track_all(true));
1686 for i in 0..MAX_AUTO_NAMES + 8 {
1687 on_param_write(&format!("P{}", i), "assign", "v");
1688 }
1689 assert_eq!(tracked_names().len(), MAX_AUTO_NAMES);
1690 assert_eq!(auto_dropped(), 8, "the overflow is counted, not stored");
1691 }
1692
1693 #[test]
1694 fn a_function_records_its_definition_calls_and_removal() {
1695 let _g = setup();
1696 assert!(set_track_all(true));
1697 on_func_define("build", None, Some("/tmp/lib.zsh"), 12);
1698 note_line(40);
1699 on_func_call("build", &[], None, Some("/tmp/lib.zsh"), 12);
1700 on_func_define("build", None, Some("/tmp/lib.zsh"), 80);
1701 on_func_unset("build");
1702 let node = lookup_func("build").expect("the function has a chain");
1703 assert_eq!(node.origin, "function build");
1704 assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
1705 assert_eq!(node.origin_site.line, 12, "origin is the first definition");
1706 let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1707 assert_eq!(
1708 ops,
1709 vec!["call", "redefine", "unfunction"],
1710 "{:?}",
1711 node.ops
1712 );
1713 assert_eq!(
1714 node.ops[0].site.line, 40,
1715 "the call op is the caller's site"
1716 );
1717 assert_eq!(
1718 node.ops[1].site.line, 80,
1719 "the redefine op is the new body's"
1720 );
1721 assert_eq!(tracked_func_names(), vec!["build".to_string()]);
1722 assert!(
1723 lookup_name("build").is_none(),
1724 "the parameter namespace is separate"
1725 );
1726 }
1727
1728 #[test]
1736 fn arming_a_function_before_it_exists_leaves_the_origin_to_its_definition() {
1737 let _g = setup();
1738 assert!(track_func("later", None, None, 0));
1740 assert!(
1741 lookup_func("later").is_none(),
1742 "no definition has happened yet, so there is nothing to attribute"
1743 );
1744 assert_eq!(
1745 tracked_func_names(),
1746 vec!["later".to_string()],
1747 "but it IS armed"
1748 );
1749
1750 on_func_define("later", None, Some("/tmp/lib.zsh"), 7);
1751 let node = lookup_func("later").expect("the definition creates the chain");
1752 assert_eq!(node.origin, "function later");
1753 assert_eq!(
1754 node.origin_site.line, 7,
1755 "the origin is where it was defined"
1756 );
1757 assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
1758 assert!(
1759 node.ops.is_empty(),
1760 "the first definition is the origin, not a redefine: {:?}",
1761 node.ops
1762 );
1763
1764 on_func_define("later", None, Some("/tmp/lib.zsh"), 20);
1766 let node = lookup_func("later").expect("still tracked");
1767 assert_eq!(node.origin_site.line, 7, "the origin does not move");
1768 let ops: Vec<&str> = node.ops.iter().map(|o| o.op.as_str()).collect();
1769 assert_eq!(ops, vec!["redefine"], "{:?}", node.ops);
1770 assert_eq!(node.ops[0].site.line, 20);
1771 }
1772
1773 #[test]
1777 fn arming_an_existing_function_seeds_the_origin_from_its_definition_site() {
1778 let _g = setup();
1779 assert!(track_func("known", None, Some("/tmp/lib.zsh"), 3));
1780 let node = lookup_func("known").expect("armed with a known definition site");
1781 assert_eq!(node.origin_site.line, 3);
1782 assert_eq!(node.origin_site.file.as_deref(), Some("/tmp/lib.zsh"));
1783 assert!(node.ops.is_empty());
1784
1785 assert!(track_func("known", None, Some("/tmp/lib.zsh"), 3));
1787 let node = lookup_func("known").expect("still there");
1788 assert_eq!(node.origin_site.line, 3);
1789 assert_eq!(tracked_func_names(), vec!["known".to_string()]);
1790 }
1791
1792 #[test]
1793 fn an_unarmed_function_records_nothing_without_track_all() {
1794 let _g = setup();
1795 on_func_define("quiet", None, Some("/tmp/lib.zsh"), 1);
1796 on_func_call("quiet", &[], None, Some("/tmp/lib.zsh"), 1);
1797 assert!(lookup_func("quiet").is_none());
1798 assert!(track_func("quiet", None, Some("/tmp/lib.zsh"), 1));
1799 on_func_call("quiet", &[], None, Some("/tmp/lib.zsh"), 1);
1800 let node = lookup_func("quiet").expect("armed by name");
1801 assert_eq!(
1802 node.ops.len(),
1803 1,
1804 "only the call after arming: {:?}",
1805 node.ops
1806 );
1807 assert!(untrack_func("quiet"));
1808 assert!(!active(), "the last untrack disarms the hot gate");
1809 }
1810
1811 #[test]
1812 fn unset_is_recorded_as_the_final_op() {
1813 let _g = setup();
1814 track_name("Z", Some("v"));
1815 on_param_unset("Z");
1816 let node = lookup_name("Z").unwrap();
1817 assert_eq!(node.ops.last().map(|o| o.op.as_str()), Some("unset"));
1818 }
1819
1820 #[test]
1821 fn render_and_json_carry_the_whole_chain() {
1822 let _g = setup();
1823 note_line(12);
1824 track_name("R", None);
1825 on_cmd_subst("echo hi", "hi");
1826 on_param_write("R", "assign", "hi");
1827 let node = lookup_name("R").unwrap();
1828 let text = render("R", &node);
1829 assert!(text.contains("origin: cmdsubst"), "text = {}", text);
1830 assert!(text.contains("line 12"), "text = {}", text);
1831 let json = render_json("R", &node);
1832 assert!(json.starts_with("{\"name\":\"R\""), "json = {}", json);
1833 assert!(json.contains("\"op\":\"assign\""), "json = {}", json);
1834 assert!(json.ends_with("\"dropped_ops\":0}"), "json = {}", json);
1835 }
1836
1837 #[test]
1841 fn concurrent_hooks_keep_the_ledger_coherent() {
1842 let _g = setup();
1843 note_line(1);
1844 track_name("SHARED", Some("seed"));
1845 let handles: Vec<_> = (0..8)
1846 .map(|i| {
1847 std::thread::spawn(move || {
1848 for n in 0..50 {
1849 let out = format!("t{}-{}", i, n);
1850 on_cmd_subst("worker", &out);
1851 let v = Value::str(out.clone());
1852 on_param_read("SHARED", &v);
1853 on_exec("exec", &["cmd".into(), out]);
1854 assert!(lookup_name("SHARED").is_some());
1855 }
1856 })
1857 })
1858 .collect();
1859 for h in handles {
1860 h.join().expect("thread");
1861 }
1862 let node = lookup_name("SHARED").expect("origin still live");
1863 assert_eq!(node.origin_site.line, 1);
1864 assert_eq!(node.owner.as_deref(), Some("SHARED"));
1865 }
1866}