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