1use std::collections::{HashMap, HashSet, VecDeque};
21
22#[cfg(any(feature = "active-core", feature = "json"))]
23use crate::error::{Error, Result};
24use crate::ir::{AnnotationKind, InfinityModule, Inode, NodeId, ProvEntry, ProvValue, Provenance};
25#[cfg(feature = "active-core")]
26use crate::ir::{
27 BinOp, Constraint, InterpPart, PatchOp, SchemaFieldSpec, SemanticTag, TypeSpec, UnOp,
28};
29#[cfg(feature = "active-core")]
30use crate::unit::UnitTag;
31
32pub const SECRET_MARKER: &str = "***SECRET***";
34
35pub fn secret_texts(module: &InfinityModule) -> Vec<String> {
40 module
41 .nodes()
42 .iter()
43 .filter_map(|node| match node {
44 Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
45 _ => None,
46 })
47 .collect()
48}
49
50pub fn redact_text(text: &str, secrets: &[String]) -> String {
52 let mut out = text.to_string();
53 for secret in secrets {
54 if secret.is_empty() || !out.contains(secret.as_str()) {
55 continue;
56 }
57 if out == *secret || secret.len() >= 4 {
60 out = out.replace(secret.as_str(), SECRET_MARKER);
61 }
62 }
63 out
64}
65
66#[cfg(feature = "active-core")]
71mod bridge {
72 use super::*;
73 use active_core::ir::{
74 Annotation as CoreAnnotation, AnnotationKind as CoreAnnotationKind, Module as CoreModule,
75 Node as CoreNode, Value as CoreValue,
76 };
77 use active_core::types::{
78 ActiveType as CoreType, Constraint as CoreConstraint, Unit as CoreUnit,
79 };
80
81 pub fn from_active_core(module: &CoreModule) -> Result<InfinityModule> {
83 let mut out = InfinityModule::new();
84 let mut map: HashMap<u32, NodeId> = HashMap::new();
85 for index in 0..module.arena.len() {
88 let core_id = active_core::NodeId(index as u32);
89 if module.get(core_id).is_none() {
90 continue;
91 }
92 let node = lower_node(module, core_id, &mut out, &mut map)?;
93 map.insert(core_id.0, node);
94 }
95 for (name, core_id) in &module.bindings {
96 if let Some(id) = map.get(&core_id.0) {
97 out.bind(name.clone(), *id);
98 }
99 }
100 if let Some(root) = module.root {
101 out.set_root(map.get(&root.0).copied());
102 }
103 let mut annotations: Vec<(NodeId, AnnotationKind)> = Vec::new();
104 for (core_id, list) in &module.annotations {
105 let Some(node) = map.get(&core_id.0).copied() else {
106 continue;
107 };
108 for annotation in list {
109 let kind = match &annotation.kind {
110 CoreAnnotationKind::Intent(text) => AnnotationKind::Intent(text.clone()),
111 CoreAnnotationKind::Constraint { source, expr } => {
112 let expr = map
113 .get(&expr.0)
114 .copied()
115 .ok_or(Error::EncodeDangling(expr.0))?;
116 AnnotationKind::Constraint {
117 source: source.clone(),
118 expr,
119 }
120 }
121 CoreAnnotationKind::AiImmutable(flag) => AnnotationKind::AiImmutable(*flag),
122 };
123 annotations.push((node, kind));
124 }
125 }
126 for (node, kind) in annotations {
127 out.annotate(node, kind);
128 }
129 out.validate_structure(u32::MAX as u64, 0)?;
130 Ok(out)
131 }
132
133 fn lower_node(
134 module: &CoreModule,
135 id: active_core::NodeId,
136 out: &mut InfinityModule,
137 map: &mut HashMap<u32, NodeId>,
138 ) -> Result<NodeId> {
139 let node = module.get(id).ok_or(Error::EncodeDangling(id.0))?;
140 if let CoreNode::Literal(value) = node {
143 return push_value(value, out, map);
144 }
145 let lowered = match node {
146 CoreNode::Literal(_) => unreachable!("handled before the match"),
147 CoreNode::Env { name, default } => {
148 let default = match default {
149 Some(default) => Some(lower_ref(module, *default, out, map)?),
150 None => None,
151 };
152 Inode::Env {
153 name: name.clone(),
154 default,
155 }
156 }
157 CoreNode::Secret { locator } => Inode::SecretRef {
158 locator: lower_ref(module, *locator, out, map)?,
159 },
160 CoreNode::Ref(target) => Inode::Ref(lower_ref(module, *target, out, map)?),
161 CoreNode::Ident(name) => Inode::Ident(name.clone()),
162 CoreNode::Interpolate { parts } => Inode::Interpolate {
163 parts: parts
164 .iter()
165 .map(|part| match part {
166 active_core::InterpPart::Literal(text) => {
167 Ok(InterpPart::Literal(text.clone()))
168 }
169 active_core::InterpPart::Expr(id) => {
170 Ok(InterpPart::Expr(lower_ref(module, *id, out, map)?))
171 }
172 })
173 .collect::<Result<Vec<_>>>()?,
174 },
175 CoreNode::Binary { op, lhs, rhs } => Inode::Binary {
176 op: lower_binop(*op),
177 lhs: lower_ref(module, *lhs, out, map)?,
178 rhs: lower_ref(module, *rhs, out, map)?,
179 },
180 CoreNode::Unary { op, expr } => Inode::Unary {
181 op: lower_unop(*op),
182 expr: lower_ref(module, *expr, out, map)?,
183 },
184 CoreNode::Array { elements } => Inode::Array {
185 elements: elements
186 .iter()
187 .map(|id| lower_ref(module, *id, out, map))
188 .collect::<Result<Vec<_>>>()?,
189 },
190 CoreNode::Table { fields } => Inode::Table {
191 fields: fields
192 .iter()
193 .map(|(name, id)| Ok((name.clone(), lower_ref(module, *id, out, map)?)))
194 .collect::<Result<Vec<_>>>()?,
195 },
196 CoreNode::Quantity { value, unit } => Inode::Quantity {
197 value: lower_ref(module, *value, out, map)?,
198 unit: UnitTag::from(unit),
199 },
200 CoreNode::ApplyConstraint { target, constraint } => Inode::Constrained {
201 target: lower_ref(module, *target, out, map)?,
202 constraint: lower_constraint(constraint)?,
203 },
204 CoreNode::PatchDirective { op } => Inode::Patch(match op {
205 active_core::ir::PatchOp::Set { path, value } => PatchOp::Set {
206 path: path.clone(),
207 value: lower_ref(module, *value, out, map)?,
208 },
209 active_core::ir::PatchOp::Remove { path } => PatchOp::Remove { path: path.clone() },
210 active_core::ir::PatchOp::Rename { from, to } => PatchOp::Rename {
211 from: from.clone(),
212 to: to.clone(),
213 },
214 }),
215 CoreNode::SchemaField { field } => Inode::SchemaField(SchemaFieldSpec {
216 typ: lower_type(&field.typ)?,
217 min: lower_opt(module, field.min, out, map)?,
218 max: lower_opt(module, field.max, out, map)?,
219 min_len: lower_opt(module, field.min_len, out, map)?,
220 max_len: lower_opt(module, field.max_len, out, map)?,
221 pattern: lower_opt(module, field.pattern, out, map)?,
222 default: lower_opt(module, field.default, out, map)?,
223 deprecated: field.deprecated.clone(),
224 }),
225 CoreNode::SchemaTable { fields, closed } => Inode::SchemaTable {
226 fields: fields
227 .iter()
228 .map(|(name, id)| Ok((name.clone(), lower_ref(module, *id, out, map)?)))
229 .collect::<Result<Vec<_>>>()?,
230 closed: *closed,
231 },
232 CoreNode::SchemaArray { element } => Inode::SchemaArray {
233 element: lower_ref(module, *element, out, map)?,
234 },
235 CoreNode::Semantic { tag, inner } => Inode::Semantic {
236 tag: match tag {
237 active_core::ir::SemanticTag::Prompt => SemanticTag::Prompt,
238 active_core::ir::SemanticTag::Policy => SemanticTag::Policy,
239 active_core::ir::SemanticTag::Template => SemanticTag::Template,
240 },
241 inner: lower_ref(module, *inner, out, map)?,
242 },
243 };
244 let id = out.push(lowered);
245 Ok(id)
246 }
247
248 fn lower_value(value: &CoreValue) -> Inode {
251 match value {
252 CoreValue::Null => Inode::Null,
253 CoreValue::Bool(flag) => Inode::Bool(*flag),
254 CoreValue::Int(number) => Inode::Int(*number),
255 CoreValue::Float(number) => Inode::Float(*number),
256 CoreValue::String(text) => Inode::Str(text.clone()),
257 CoreValue::DurationMs(ms) => Inode::DurationMs(*ms),
258 CoreValue::Secret(text) => Inode::Secret(crate::ir::SecretBytes::from(text.clone())),
259 CoreValue::Quantity(..) | CoreValue::Array(_) | CoreValue::Table(_) => {
262 unreachable!("composed values are lowered by push_value")
263 }
264 }
265 }
266
267 fn push_value(
268 value: &CoreValue,
269 out: &mut InfinityModule,
270 map: &mut HashMap<u32, NodeId>,
271 ) -> Result<NodeId> {
272 let _ = map;
273 let node = match value {
274 CoreValue::Quantity(amount, unit) => {
275 let number = out.push(Inode::Float(*amount));
276 Inode::Quantity {
277 value: number,
278 unit: UnitTag::from(unit),
279 }
280 }
281 CoreValue::Array(items) => {
282 let mut elements = Vec::with_capacity(items.len());
283 for item in items {
284 elements.push(push_value(item, out, map)?);
285 }
286 Inode::Array { elements }
287 }
288 CoreValue::Table(entries) => {
289 let mut fields = Vec::with_capacity(entries.len());
290 for (name, item) in entries {
291 fields.push((name.clone(), push_value(item, out, map)?));
292 }
293 Inode::Table { fields }
294 }
295 other => lower_value(other),
296 };
297 Ok(out.push(node))
298 }
299
300 fn lower_ref(
301 module: &CoreModule,
302 id: active_core::NodeId,
303 out: &mut InfinityModule,
304 map: &mut HashMap<u32, NodeId>,
305 ) -> Result<NodeId> {
306 if let Some(mapped) = map.get(&id.0) {
307 return Ok(*mapped);
308 }
309 let lowered = lower_node(module, id, out, map)?;
310 map.insert(id.0, lowered);
311 Ok(lowered)
312 }
313
314 fn lower_opt(
315 module: &CoreModule,
316 id: Option<active_core::NodeId>,
317 out: &mut InfinityModule,
318 map: &mut HashMap<u32, NodeId>,
319 ) -> Result<Option<NodeId>> {
320 match id {
321 Some(id) => Ok(Some(lower_ref(module, id, out, map)?)),
322 None => Ok(None),
323 }
324 }
325
326 fn lower_binop(op: active_core::ir::BinOp) -> BinOp {
327 match op {
328 active_core::ir::BinOp::Add => BinOp::Add,
329 active_core::ir::BinOp::Sub => BinOp::Sub,
330 active_core::ir::BinOp::Mul => BinOp::Mul,
331 active_core::ir::BinOp::Div => BinOp::Div,
332 active_core::ir::BinOp::Eq => BinOp::Eq,
333 active_core::ir::BinOp::Ne => BinOp::Ne,
334 active_core::ir::BinOp::Lt => BinOp::Lt,
335 active_core::ir::BinOp::Le => BinOp::Le,
336 active_core::ir::BinOp::Gt => BinOp::Gt,
337 active_core::ir::BinOp::Ge => BinOp::Ge,
338 active_core::ir::BinOp::And => BinOp::And,
339 active_core::ir::BinOp::Or => BinOp::Or,
340 }
341 }
342
343 fn lower_unop(op: active_core::ir::UnOp) -> UnOp {
344 match op {
345 active_core::ir::UnOp::Neg => UnOp::Neg,
346 active_core::ir::UnOp::Not => UnOp::Not,
347 }
348 }
349
350 fn lower_constraint(constraint: &CoreConstraint) -> Result<Constraint> {
351 let converted = match constraint {
352 CoreConstraint::MinMax { min, max } => Constraint::MinMax {
353 min: *min,
354 max: *max,
355 },
356 CoreConstraint::Enum(items) => Constraint::Enum(items.clone()),
357 CoreConstraint::Pattern(pattern) => Constraint::Pattern(pattern.clone()),
358 CoreConstraint::Odd => Constraint::Odd,
359 CoreConstraint::Custom(text) => Constraint::Custom(text.clone()),
360 };
361 Ok(converted)
362 }
363
364 fn lower_type(typ: &CoreType) -> Result<TypeSpec> {
365 let spec = match typ {
366 CoreType::Null => TypeSpec::Null,
367 CoreType::Bool => TypeSpec::Bool,
368 CoreType::Int => TypeSpec::Int,
369 CoreType::Float => TypeSpec::Float,
370 CoreType::String => TypeSpec::String,
371 CoreType::Duration => TypeSpec::Duration,
372 CoreType::Bytes => TypeSpec::Bytes,
373 CoreType::Table { closed } => TypeSpec::Table { closed: *closed },
374 CoreType::Enum(items) => TypeSpec::Enum(items.clone()),
375 CoreType::Secret => TypeSpec::Secret,
376 CoreType::Any => TypeSpec::Any,
377 };
378 Ok(spec)
379 }
380
381 pub fn to_active_core(module: &InfinityModule) -> Result<CoreModule> {
387 let mut out = CoreModule::new("infinity");
388 for node in module.nodes() {
389 out.add_node(convert_node(node)?);
390 }
391 for (index, node) in module.nodes().iter().enumerate() {
393 let dependent = active_core::NodeId(index as u32);
394 for child in node.children() {
395 out.add_edge(active_core::NodeId(child.0), dependent);
396 }
397 }
398 for (name, id) in module.bindings() {
399 out.bindings.insert(name.clone(), active_core::NodeId(id.0));
400 }
401 out.root = module.root().map(|id| active_core::NodeId(id.0));
402 for annotation in module.annotations() {
403 let core = CoreAnnotation {
404 kind: convert_annotation(&annotation.kind)?,
405 path: format!("node{}", annotation.node.0),
406 span: active_core::diag::Span {
407 line: 0,
408 column: 0,
409 end_line: 0,
410 end_column: 0,
411 },
412 };
413 out.annotations
414 .entry(active_core::NodeId(annotation.node.0))
415 .or_default()
416 .push(core);
417 }
418 Ok(out)
419 }
420
421 fn convert_annotation(kind: &AnnotationKind) -> Result<CoreAnnotationKind> {
422 Ok(match kind {
423 AnnotationKind::Intent(text) => CoreAnnotationKind::Intent(text.clone()),
424 AnnotationKind::Constraint { source, expr } => CoreAnnotationKind::Constraint {
425 source: source.clone(),
426 expr: active_core::NodeId(expr.0),
427 },
428 AnnotationKind::AiImmutable(flag) => CoreAnnotationKind::AiImmutable(*flag),
429 })
430 }
431
432 fn convert_node(node: &Inode) -> Result<CoreNode> {
433 let converted = match node {
434 Inode::Null => CoreNode::Literal(CoreValue::Null),
435 Inode::Bool(flag) => CoreNode::Literal(CoreValue::Bool(*flag)),
436 Inode::Int(number) => CoreNode::Literal(CoreValue::Int(*number)),
437 Inode::Float(number) => CoreNode::Literal(CoreValue::Float(*number)),
438 Inode::Str(text) => CoreNode::Literal(CoreValue::String(text.clone())),
439 Inode::DurationMs(ms) => CoreNode::Literal(CoreValue::DurationMs(*ms)),
440 Inode::Quantity { value, unit } => CoreNode::Quantity {
441 value: active_core::NodeId(value.0),
442 unit: CoreUnit::from(unit),
443 },
444 Inode::Array { elements } => CoreNode::Array {
445 elements: elements
446 .iter()
447 .map(|id| active_core::NodeId(id.0))
448 .collect(),
449 },
450 Inode::Table { fields } => CoreNode::Table {
451 fields: fields
452 .iter()
453 .map(|(name, id)| (name.clone(), active_core::NodeId(id.0)))
454 .collect(),
455 },
456 Inode::Secret(secret) => CoreNode::Literal(CoreValue::Secret(
457 String::from_utf8_lossy(secret.expose()).into_owned(),
458 )),
459 Inode::SecretRef { locator } => CoreNode::Secret {
460 locator: active_core::NodeId(locator.0),
461 },
462 Inode::Ref(id) => CoreNode::Ref(active_core::NodeId(id.0)),
463 Inode::Env { name, default } => CoreNode::Env {
464 name: name.clone(),
465 default: default.map(|id| active_core::NodeId(id.0)),
466 },
467 Inode::Ident(name) => CoreNode::Ident(name.clone()),
468 Inode::Interpolate { parts } => CoreNode::Interpolate {
469 parts: parts
470 .iter()
471 .map(|part| match part {
472 InterpPart::Literal(text) => active_core::InterpPart::Literal(text.clone()),
473 InterpPart::Expr(id) => {
474 active_core::InterpPart::Expr(active_core::NodeId(id.0))
475 }
476 })
477 .collect(),
478 },
479 Inode::Binary { op, lhs, rhs } => CoreNode::Binary {
480 op: convert_binop(*op),
481 lhs: active_core::NodeId(lhs.0),
482 rhs: active_core::NodeId(rhs.0),
483 },
484 Inode::Unary { op, expr } => CoreNode::Unary {
485 op: match op {
486 UnOp::Neg => active_core::ir::UnOp::Neg,
487 UnOp::Not => active_core::ir::UnOp::Not,
488 },
489 expr: active_core::NodeId(expr.0),
490 },
491 Inode::Constrained { target, constraint } => CoreNode::ApplyConstraint {
492 target: active_core::NodeId(target.0),
493 constraint: convert_constraint(constraint),
494 },
495 Inode::Patch(op) => CoreNode::PatchDirective {
496 op: match op {
497 PatchOp::Set { path, value } => active_core::ir::PatchOp::Set {
498 path: path.clone(),
499 value: active_core::NodeId(value.0),
500 },
501 PatchOp::Remove { path } => {
502 active_core::ir::PatchOp::Remove { path: path.clone() }
503 }
504 PatchOp::Rename { from, to } => active_core::ir::PatchOp::Rename {
505 from: from.clone(),
506 to: to.clone(),
507 },
508 },
509 },
510 Inode::SchemaField(spec) => CoreNode::SchemaField {
511 field: active_core::SchemaField {
512 typ: convert_type(&spec.typ)?,
513 min: spec.min.map(|id| active_core::NodeId(id.0)),
514 max: spec.max.map(|id| active_core::NodeId(id.0)),
515 min_len: spec.min_len.map(|id| active_core::NodeId(id.0)),
516 max_len: spec.max_len.map(|id| active_core::NodeId(id.0)),
517 pattern: spec.pattern.map(|id| active_core::NodeId(id.0)),
518 default: spec.default.map(|id| active_core::NodeId(id.0)),
519 deprecated: spec.deprecated.clone(),
520 span: None,
521 },
522 },
523 Inode::SchemaTable { fields, closed } => CoreNode::SchemaTable {
524 fields: fields
525 .iter()
526 .map(|(name, id)| (name.clone(), active_core::NodeId(id.0)))
527 .collect(),
528 closed: *closed,
529 },
530 Inode::SchemaArray { element } => CoreNode::SchemaArray {
531 element: active_core::NodeId(element.0),
532 },
533 Inode::Semantic { tag, inner } => CoreNode::Semantic {
534 tag: match tag {
535 SemanticTag::Prompt => active_core::ir::SemanticTag::Prompt,
536 SemanticTag::Policy => active_core::ir::SemanticTag::Policy,
537 SemanticTag::Template => active_core::ir::SemanticTag::Template,
538 },
539 inner: active_core::NodeId(inner.0),
540 },
541 Inode::ContentAddressed { .. } => {
542 return Err(Error::Encode(
543 "active-core 1.4.0 has no ContentAddressed node kind",
544 ))
545 }
546 Inode::Blob { .. } => {
547 return Err(Error::Encode(
550 "active-core 1.4.0 has no blob value; keep the module in Infinity",
551 ));
552 }
553 };
554 Ok(converted)
555 }
556
557 fn convert_binop(op: BinOp) -> active_core::ir::BinOp {
558 match op {
559 BinOp::Add => active_core::ir::BinOp::Add,
560 BinOp::Sub => active_core::ir::BinOp::Sub,
561 BinOp::Mul => active_core::ir::BinOp::Mul,
562 BinOp::Div => active_core::ir::BinOp::Div,
563 BinOp::Eq => active_core::ir::BinOp::Eq,
564 BinOp::Ne => active_core::ir::BinOp::Ne,
565 BinOp::Lt => active_core::ir::BinOp::Lt,
566 BinOp::Le => active_core::ir::BinOp::Le,
567 BinOp::Gt => active_core::ir::BinOp::Gt,
568 BinOp::Ge => active_core::ir::BinOp::Ge,
569 BinOp::And => active_core::ir::BinOp::And,
570 BinOp::Or => active_core::ir::BinOp::Or,
571 }
572 }
573
574 fn convert_constraint(constraint: &Constraint) -> CoreConstraint {
575 match constraint {
576 Constraint::MinMax { min, max } => CoreConstraint::MinMax {
577 min: *min,
578 max: *max,
579 },
580 Constraint::Enum(items) => CoreConstraint::Enum(items.clone()),
581 Constraint::Pattern(pattern) => CoreConstraint::Pattern(pattern.clone()),
582 Constraint::Odd => CoreConstraint::Odd,
583 Constraint::Custom(text) => CoreConstraint::Custom(text.clone()),
584 }
585 }
586
587 fn convert_type(typ: &TypeSpec) -> Result<CoreType> {
588 let spec = match typ {
589 TypeSpec::Null => CoreType::Null,
590 TypeSpec::Bool => CoreType::Bool,
591 TypeSpec::Int => CoreType::Int,
592 TypeSpec::Float => CoreType::Float,
593 TypeSpec::String => CoreType::String,
594 TypeSpec::Duration => CoreType::Duration,
595 TypeSpec::Bytes => CoreType::Bytes,
596 TypeSpec::Table { closed } => CoreType::Table { closed: *closed },
597 TypeSpec::Enum(items) => CoreType::Enum(items.clone()),
598 TypeSpec::Secret => CoreType::Secret,
599 TypeSpec::Any => CoreType::Any,
600 TypeSpec::Blob => {
601 return Err(Error::Encode("active-core 1.4.0 has no Blob type"));
604 }
605 };
606 Ok(spec)
607 }
608
609 pub fn evaluate(
611 module: &InfinityModule,
612 budget: active_core::Budget,
613 ) -> Result<(active_core::Value, Vec<active_core::Diagnostic>)> {
614 let core = to_active_core(module)?;
615 let mut engine = active_core::Engine::new();
616 engine.budget = budget;
617 engine.evaluate(&core).map_err(|error| {
618 Error::Evaluation(active_error_code(error.code()))
621 })
622 }
623
624 fn active_error_code(code: &'static str) -> &'static str {
625 match code {
626 "S001" => "active/S001-cycle",
627 "S002" => "active/S002-missing-root",
628 "S003" => "active/S003-missing-node",
629 "T001" => "active/T001-type",
630 "X001" => "active/X001-node-budget",
631 "X002" => "active/X002-wall-clock",
632 "X003" => "active/X003-depth",
633 "X004" => "active/X004-array-length",
634 "X005" => "active/X005-keys",
635 "X006" => "active/X006-value-size",
636 _ => "active/eval-error",
637 }
638 }
639}
640
641#[cfg(feature = "active-core")]
642pub use bridge::{evaluate, from_active_core, to_active_core};
643
644#[cfg(feature = "json")]
649mod projection {
650 use super::*;
651
652 pub fn to_redacted_json(module: &InfinityModule) -> Result<String> {
660 module.validate_structure(u32::MAX as u64, 4096)?;
663 let secrets = secret_literals(module);
664 let value = build_value(module, module.root(), &secrets, 0)?;
665 serde_json::to_string(&value).map_err(|_| Error::Encode("json projection failed"))
666 }
667
668 pub fn to_redacted_json_pretty(module: &InfinityModule) -> Result<String> {
670 module.validate_structure(u32::MAX as u64, 4096)?;
671 let secrets = secret_literals(module);
672 let value = build_value(module, module.root(), &secrets, 0)?;
673 serde_json::to_string_pretty(&value).map_err(|_| Error::Encode("json projection failed"))
674 }
675
676 pub fn secret_literals(module: &InfinityModule) -> Vec<String> {
678 module
679 .nodes()
680 .iter()
681 .filter_map(|node| match node {
682 Inode::Secret(secret) => {
683 Some(String::from_utf8_lossy(secret.expose()).into_owned())
684 }
685 _ => None,
686 })
687 .collect()
688 }
689
690 fn redact_plain(text: &str, secrets: &[String]) -> String {
693 let mut out = text.to_string();
694 for secret in secrets {
695 if secret.is_empty() || !out.contains(secret.as_str()) {
696 continue;
697 }
698 out = out.replace(secret.as_str(), SECRET_MARKER);
699 }
700 out
701 }
702
703 fn key_string(text: &str, secrets: &[String]) -> String {
705 redact_plain(text, secrets)
706 }
707
708 fn redact_string(text: &str, secrets: &[String]) -> serde_json::Value {
709 if secrets
710 .iter()
711 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
712 {
713 serde_json::Value::from(SECRET_MARKER)
714 } else {
715 serde_json::Value::from(text)
716 }
717 }
718
719 const PROJECTION_DEPTH: usize = 1024;
723
724 fn build_value(
725 module: &InfinityModule,
726 id: Option<NodeId>,
727 secrets: &[String],
728 depth: usize,
729 ) -> Result<serde_json::Value> {
730 if depth > PROJECTION_DEPTH {
731 return Err(Error::Encode("projection nesting exceeds the limit"));
732 }
733 let value = match id.and_then(|id| module.get(id)) {
734 None => serde_json::Value::Null,
735 Some(node) => match node {
736 Inode::Null => serde_json::Value::Null,
737 Inode::Bool(flag) => serde_json::Value::from(*flag),
738 Inode::Int(number) => serde_json::Value::from(*number),
739 Inode::Float(number) => serde_json::Value::from(*number),
740 Inode::Str(text) | Inode::Ident(text) => redact_string(text, secrets),
741 Inode::DurationMs(ms) => serde_json::json!({ "ms": ms }),
742 Inode::Quantity { value, unit } => serde_json::json!({
743 "value": build_value(module, Some(*value), secrets, depth + 1)?,
744 "unit": redact_string(unit.name(), secrets),
745 }),
746 Inode::Array { elements } => serde_json::Value::Array(
747 elements
748 .iter()
749 .map(|id| build_value(module, Some(*id), secrets, depth + 1))
750 .collect::<Result<Vec<_>>>()?,
751 ),
752 Inode::Table { fields } => {
753 let mut map = serde_json::Map::new();
754 for (name, id) in fields {
755 map.insert(
758 key_string(name, secrets),
759 build_value(module, Some(*id), secrets, depth + 1)?,
760 );
761 }
762 serde_json::Value::Object(map)
763 }
764 Inode::Secret(_) | Inode::SecretRef { .. } => {
765 serde_json::Value::from(SECRET_MARKER)
766 }
767 Inode::Ref(id) => build_value(module, Some(*id), secrets, depth + 1)?,
768 Inode::Env { name, .. } => {
769 serde_json::json!({ "$env": redact_string(name, secrets) })
770 }
771 Inode::Interpolate { parts } => {
772 let mut text = String::new();
773 let mut tainted = false;
774 for part in parts {
775 match part {
776 InterpPart::Literal(literal) => text.push_str(literal),
777 InterpPart::Expr(id) => match id.index() {
778 _ if is_secret(module, *id) => tainted = true,
779 _ => text.push_str("${}"),
780 },
781 }
782 }
783 if tainted {
784 serde_json::Value::from(SECRET_MARKER)
785 } else {
786 redact_string(&text, secrets)
787 }
788 }
789 Inode::Binary { .. }
790 | Inode::Unary { .. }
791 | Inode::Constrained { .. }
792 | Inode::Patch(_)
793 | Inode::SchemaField(_)
794 | Inode::SchemaTable { .. }
795 | Inode::SchemaArray { .. }
796 | Inode::ContentAddressed { .. } => {
797 serde_json::json!({ "$expr": crate::view::node_kind_name(node.kind_code()) })
798 }
799 Inode::Semantic { inner, .. } => {
800 build_value(module, Some(*inner), secrets, depth + 1)?
801 }
802 Inode::Blob {
803 hash,
804 size,
805 tainted,
806 media_type,
807 ..
808 } => serde_json::json!({
809 "$active.blob": {
810 "hash": format!("blake3:{}", crate::ir::hash_hex(hash)),
811 "size": size,
812 "media_type": if *tainted {
813 SECRET_MARKER.to_string()
814 } else {
815 redact_plain(media_type, secrets)
816 },
817 "tainted": tainted,
818 }
819 }),
820 },
821 };
822 Ok(value)
823 }
824
825 fn is_secret(module: &InfinityModule, id: NodeId) -> bool {
826 module.get(id).map(|node| node.is_secret()).unwrap_or(false)
827 }
828
829 pub fn provenance_json(prov: &Provenance, secrets: &[String]) -> serde_json::Value {
832 let text = |value: &str| redact_string(value, secrets);
833 let mut map = serde_json::Map::new();
834 if let Some(producer) = &prov.producer {
835 map.insert("producer".into(), text(producer));
836 }
837 if prov.timestamp_ms != 0 {
838 map.insert(
839 "timestamp_ms".into(),
840 serde_json::Value::from(prov.timestamp_ms),
841 );
842 }
843 if let Some(model) = &prov.model {
844 map.insert("model".into(), text(model));
845 }
846 if let Some(tool) = &prov.tool {
847 map.insert("tool".into(), text(tool));
848 }
849 if let Some(parent) = prov.parent {
850 map.insert(
851 "parent".into(),
852 serde_json::Value::from(crate::ir::hash_hex(&parent)),
853 );
854 }
855 let mut entries = serde_json::Map::new();
856 for entry in &prov.entries {
857 let value = match &entry.value {
858 ProvValue::Text(t) => text(t),
859 ProvValue::Int(number) => serde_json::Value::from(*number),
860 ProvValue::Flag(flag) => serde_json::Value::from(*flag),
861 ProvValue::Hash(hash) => serde_json::Value::from(crate::ir::hash_hex(hash)),
862 };
863 entries.insert(key_string(&entry.key, secrets), value);
864 }
865 map.insert("entries".into(), serde_json::Value::Object(entries));
866 serde_json::Value::Object(map)
867 }
868
869 pub fn provenance_json_for(module: &InfinityModule) -> Option<serde_json::Value> {
871 let secrets = secret_literals(module);
872 module
873 .provenance()
874 .map(|prov| provenance_json(prov, &secrets))
875 }
876}
877
878#[cfg(feature = "json")]
879pub use projection::{
880 provenance_json, provenance_json_for, secret_literals, to_redacted_json,
881 to_redacted_json_pretty,
882};
883
884pub fn value_paths(module: &InfinityModule) -> HashMap<NodeId, String> {
890 let mut paths: HashMap<NodeId, String> = HashMap::new();
891 for (name, id) in module.bindings() {
892 paths.insert(*id, name.clone());
893 }
894 if let Some(root) = module.root() {
895 let mut stack = vec![(root, String::new())];
896 let mut expanded = std::collections::HashSet::new();
897 while let Some((id, path)) = stack.pop() {
898 if !expanded.insert(id) {
900 continue;
901 }
902 match module.get(id) {
903 Some(Inode::Table { fields }) => {
904 for (key, child) in fields {
905 let child_path = if path.is_empty() {
906 key.clone()
907 } else {
908 format!("{path}.{key}")
909 };
910 paths.entry(*child).or_insert_with(|| child_path.clone());
911 stack.push((*child, child_path));
912 }
913 }
914 Some(Inode::SchemaTable { fields, .. }) => {
915 for (key, child) in fields {
916 let child_path = if path.is_empty() {
917 key.clone()
918 } else {
919 format!("{path}.{key}")
920 };
921 paths.entry(*child).or_insert_with(|| child_path.clone());
922 stack.push((*child, child_path));
923 }
924 }
925 _ => {}
926 }
927 }
928 }
929 paths
930}
931
932pub fn node_label(module: &InfinityModule, id: NodeId) -> String {
939 if let Some(path) = value_paths(module).get(&id) {
940 return path.clone();
941 }
942 match module.get(id) {
943 Some(node) => format!("{}[{}]", node.kind().name(), id.0),
944 None => format!("node{}", id.0),
945 }
946}
947
948pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
951 if let Some(root) = module.root() {
952 let mut current = root;
953 let mut found = true;
954 for segment in path.split('.') {
955 match module.get(current) {
956 Some(Inode::Table { fields }) | Some(Inode::SchemaTable { fields, .. }) => {
957 match fields.iter().find(|(name, _)| name == segment) {
958 Some((_, child)) => current = *child,
959 None => {
960 found = false;
961 break;
962 }
963 }
964 }
965 _ => {
966 found = false;
967 break;
968 }
969 }
970 }
971 if found {
972 return Some(current);
973 }
974 }
975 if !path.contains('.') {
976 return module
977 .bindings()
978 .iter()
979 .find(|(name, _)| name == path)
980 .map(|(_, id)| *id);
981 }
982 None
983}
984
985fn adjacency(module: &InfinityModule) -> (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) {
986 let count = module.node_count();
987 let mut deps = vec![Vec::new(); count];
988 let mut dependents = vec![Vec::new(); count];
989 for (index, node) in module.nodes().iter().enumerate() {
990 for child in node.children() {
991 if child.index() < count {
992 deps[index].push(child);
993 dependents[child.index()].push(NodeId(index as u32));
994 }
995 }
996 }
997 (deps, dependents)
998}
999
1000fn walk(adjacency: &[Vec<NodeId>], start: NodeId) -> Vec<NodeId> {
1001 let mut seen = HashSet::new();
1002 let mut out = Vec::new();
1003 let mut stack = vec![start];
1004 seen.insert(start);
1005 while let Some(id) = stack.pop() {
1006 for next in &adjacency[id.index()] {
1007 if seen.insert(*next) {
1008 out.push(*next);
1009 stack.push(*next);
1010 }
1011 }
1012 }
1013 out
1014}
1015
1016pub fn impact(module: &InfinityModule, id: NodeId) -> Vec<String> {
1021 let (_, dependents) = adjacency(module);
1022 let mut labels: Vec<String> = walk(&dependents, id)
1023 .into_iter()
1024 .map(|node| node_label(module, node))
1025 .collect();
1026 labels.sort();
1027 labels.dedup();
1028 labels
1029}
1030
1031pub fn why(module: &InfinityModule, id: NodeId) -> Vec<String> {
1033 let (deps, _) = adjacency(module);
1034 let mut labels: Vec<String> = walk(&deps, id)
1035 .into_iter()
1036 .map(|node| node_label(module, node))
1037 .collect();
1038 labels.sort();
1039 labels.dedup();
1040 labels
1041}
1042
1043pub fn dependents(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1045 let (_, dependents) = adjacency(module);
1046 let mut out = dependents.get(id.index()).cloned().unwrap_or_default();
1047 out.sort_unstable();
1048 out.dedup();
1049 out
1050}
1051
1052pub fn dependencies(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1054 let mut out = module
1055 .get(id)
1056 .map(|node| node.children())
1057 .unwrap_or_default();
1058 out.sort_unstable();
1059 out.dedup();
1060 out
1061}
1062
1063pub fn subgraph(module: &InfinityModule, roots: &[NodeId]) -> Vec<NodeId> {
1065 let (deps, _) = adjacency(module);
1066 let mut seen: HashSet<NodeId> = HashSet::new();
1067 let mut order = Vec::new();
1068 let mut queue: VecDeque<NodeId> = VecDeque::new();
1069 for root in roots {
1070 if module.get(*root).is_some() && seen.insert(*root) {
1071 order.push(*root);
1072 queue.push_back(*root);
1073 }
1074 }
1075 while let Some(node) = queue.pop_front() {
1076 for dep in &deps[node.index()] {
1077 if seen.insert(*dep) {
1078 order.push(*dep);
1079 queue.push_back(*dep);
1080 }
1081 }
1082 }
1083 order
1084}
1085
1086pub fn secret_tainted_paths(module: &InfinityModule) -> Vec<String> {
1089 let mut tainted: Vec<bool> = vec![false; module.node_count()];
1090 let order = subgraph(module, &root_ids(module));
1093 let mut changed = true;
1094 while changed {
1095 changed = false;
1096 for id in &order {
1097 let Some(node) = module.get(*id) else {
1098 continue;
1099 };
1100 let value = node.is_secret()
1101 || node
1102 .children()
1103 .iter()
1104 .any(|child| tainted.get(child.index()).copied().unwrap_or(false));
1105 if value && !tainted[id.index()] {
1106 tainted[id.index()] = true;
1107 changed = true;
1108 }
1109 }
1110 }
1111 let paths = value_paths(module);
1112 let mut out: Vec<String> = module
1113 .nodes()
1114 .iter()
1115 .enumerate()
1116 .filter(|(index, _)| tainted[*index])
1117 .filter_map(|(index, _)| paths.get(&NodeId(index as u32)).cloned())
1118 .collect();
1119 out.sort();
1120 out.dedup();
1121 out
1122}
1123
1124fn root_ids(module: &InfinityModule) -> Vec<NodeId> {
1125 let mut roots = Vec::new();
1126 if let Some(root) = module.root() {
1127 roots.push(root);
1128 }
1129 roots.extend(module.bindings().iter().map(|(_, id)| *id));
1130 roots
1131}
1132
1133pub fn provenance_lines(module: &InfinityModule) -> Vec<String> {
1136 let Some(prov) = module.provenance() else {
1137 return Vec::new();
1138 };
1139 let secrets: Vec<String> = module
1140 .nodes()
1141 .iter()
1142 .filter_map(|node| match node {
1143 Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
1144 _ => None,
1145 })
1146 .collect();
1147 let redact = |text: &str| -> String {
1148 if secrets
1149 .iter()
1150 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
1151 {
1152 SECRET_MARKER.to_string()
1153 } else {
1154 text.to_string()
1155 }
1156 };
1157 let mut lines = Vec::new();
1158 if let Some(producer) = &prov.producer {
1159 lines.push(format!("producer={}", redact(producer)));
1160 }
1161 if prov.timestamp_ms != 0 {
1162 lines.push(format!("timestamp_ms={}", prov.timestamp_ms));
1163 }
1164 if let Some(model) = &prov.model {
1165 lines.push(format!("model={}", redact(model)));
1166 }
1167 if let Some(tool) = &prov.tool {
1168 lines.push(format!("tool={}", redact(tool)));
1169 }
1170 if let Some(parent) = prov.parent {
1171 lines.push(format!("parent=blake3:{}", crate::ir::hash_hex(&parent)));
1172 }
1173 for entry in &prov.entries {
1174 let value = match &entry.value {
1175 ProvValue::Text(text) => redact(text),
1176 ProvValue::Int(number) => number.to_string(),
1177 ProvValue::Flag(flag) => flag.to_string(),
1178 ProvValue::Hash(hash) => format!("blake3:{}", crate::ir::hash_hex(hash)),
1179 };
1180 lines.push(format!("{}={value}", redact(&entry.key)));
1181 }
1182 lines
1183}
1184
1185pub fn intents(module: &InfinityModule, id: NodeId) -> Vec<String> {
1191 let secrets = secret_texts(module);
1192 module
1193 .annotations()
1194 .iter()
1195 .filter(|annotation| annotation.node == id)
1196 .filter_map(|annotation| match &annotation.kind {
1197 AnnotationKind::Intent(text) => Some(redact_text(text, &secrets)),
1198 _ => None,
1199 })
1200 .collect()
1201}
1202
1203pub fn immutable_nodes(module: &InfinityModule) -> Vec<NodeId> {
1205 let mut out: Vec<NodeId> = module
1206 .annotations()
1207 .iter()
1208 .filter_map(|annotation| match &annotation.kind {
1209 AnnotationKind::AiImmutable(true) => Some(annotation.node),
1210 _ => None,
1211 })
1212 .collect();
1213 out.sort_unstable();
1214 out.dedup();
1215 out
1216}
1217
1218pub fn provenance_pairs(prov: &Provenance) -> Vec<&ProvEntry> {
1220 let mut entries: Vec<&ProvEntry> = prov.entries.iter().collect();
1221 entries.sort_by(|a, b| a.key.cmp(&b.key));
1222 entries
1223}
1224
1225#[cfg(feature = "active-core")]
1230pub fn from_active_source(source: &str, name: &str) -> Result<InfinityModule> {
1231 let core = active_core::parse(source, name)
1232 .map_err(|_| Error::Encode("Active source failed to parse"))?;
1233 from_active_core(&core)
1234}