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 redacted_key(text: &str, secrets: &[String]) -> String {
738 redact_text(text, secrets)
739}
740
741fn resolves_to_secret(module: &InfinityModule, id: NodeId, depth: usize) -> bool {
746 if depth > PROJECTION_DEPTH {
747 return true;
749 }
750 match module.get(id) {
751 Some(Inode::Secret(_)) | Some(Inode::SecretRef { .. }) => true,
752 Some(Inode::Ref(inner)) => resolves_to_secret(module, *inner, depth + 1),
753 Some(Inode::Semantic { inner, .. }) => resolves_to_secret(module, *inner, depth + 1),
754 Some(node) => node.is_secret(),
755 None => false,
756 }
757}
758
759fn build_projected(
760 module: &InfinityModule,
761 id: Option<NodeId>,
762 secrets: &[String],
763 depth: usize,
764) -> Result<Projected> {
765 if depth > PROJECTION_DEPTH {
766 return Err(Error::Encode("projection nesting exceeds the limit"));
767 }
768 let value = match id.and_then(|id| module.get(id)) {
769 None => Projected::Null,
770 Some(node) => match node {
771 Inode::Null => Projected::Null,
772 Inode::Bool(flag) => Projected::Bool(*flag),
773 Inode::Int(number) => Projected::Int(*number),
774 Inode::Float(number) => Projected::Float(*number),
775 Inode::Str(text) | Inode::Ident(text) => Projected::Text(redacted_value(text, secrets)),
776 Inode::DurationMs(ms) => Projected::DurationMs(*ms),
777 Inode::Quantity { value, unit } => Projected::Quantity(
778 Box::new(build_projected(module, Some(*value), secrets, depth + 1)?),
779 redacted_value(unit.name(), secrets),
780 ),
781 Inode::Array { elements } => Projected::Array(
782 elements
783 .iter()
784 .map(|id| build_projected(module, Some(*id), secrets, depth + 1))
785 .collect::<Result<Vec<_>>>()?,
786 ),
787 Inode::Table { fields } => {
788 let mut projected = Vec::with_capacity(fields.len());
789 for (name, id) in fields {
790 projected.push((
791 redacted_key(name, secrets),
792 build_projected(module, Some(*id), secrets, depth + 1)?,
793 ));
794 }
795 Projected::Record(projected)
796 }
797 Inode::Secret(_) | Inode::SecretRef { .. } => {
798 Projected::Text(SECRET_MARKER.to_string())
799 }
800 Inode::Ref(id) => build_projected(module, Some(*id), secrets, depth + 1)?,
801 Inode::Env { name, .. } => Projected::Env(redacted_value(name, secrets)),
802 Inode::Interpolate { parts } => {
803 let mut text = String::new();
804 let mut tainted = false;
805 for part in parts {
806 match part {
807 InterpPart::Literal(literal) => text.push_str(literal),
808 InterpPart::Expr(id) => match id.index() {
809 _ if resolves_to_secret(module, *id, 0) => tainted = true,
810 _ => text.push_str("${}"),
811 },
812 }
813 }
814 if tainted {
815 Projected::Text(SECRET_MARKER.to_string())
816 } else {
817 Projected::Text(redacted_value(&text, secrets))
818 }
819 }
820 Inode::Binary { .. }
821 | Inode::Unary { .. }
822 | Inode::Constrained { .. }
823 | Inode::Patch(_)
824 | Inode::SchemaField(_)
825 | Inode::SchemaTable { .. }
826 | Inode::SchemaArray { .. }
827 | Inode::ContentAddressed { .. } => {
828 Projected::Expr(crate::view::node_kind_name(node.kind_code()))
829 }
830 Inode::Semantic { inner, .. } => {
831 build_projected(module, Some(*inner), secrets, depth + 1)?
832 }
833 Inode::Blob {
834 hash,
835 size,
836 tainted,
837 media_type,
838 ..
839 } => Projected::Blob {
840 hash: *hash,
841 size: *size,
842 media_type: if *tainted {
843 SECRET_MARKER.to_string()
844 } else {
845 redacted_key(media_type, secrets)
846 },
847 tainted: *tainted,
848 },
849 },
850 };
851 Ok(value)
852}
853
854#[cfg(feature = "json")]
859mod projection {
860 use super::*;
861
862 pub fn to_redacted_json(module: &InfinityModule) -> Result<String> {
870 let value = to_serde(&project_root(module)?);
871 serde_json::to_string(&value).map_err(|_| Error::Encode("json projection failed"))
872 }
873
874 pub fn to_redacted_json_pretty(module: &InfinityModule) -> Result<String> {
876 let value = to_serde(&project_root(module)?);
877 serde_json::to_string_pretty(&value).map_err(|_| Error::Encode("json projection failed"))
878 }
879
880 pub fn secret_literals(module: &InfinityModule) -> Vec<String> {
882 secret_texts(module)
883 }
884
885 fn to_serde(value: &Projected) -> serde_json::Value {
887 match value {
888 Projected::Null => serde_json::Value::Null,
889 Projected::Bool(flag) => serde_json::Value::from(*flag),
890 Projected::Int(number) => serde_json::Value::from(*number),
891 Projected::Float(number) => serde_json::Value::from(*number),
892 Projected::Text(text) => serde_json::Value::from(text.clone()),
893 Projected::DurationMs(ms) => serde_json::json!({ "ms": ms }),
894 Projected::Quantity(value, unit) => serde_json::json!({
895 "value": to_serde(value),
896 "unit": unit,
897 }),
898 Projected::Array(items) => {
899 serde_json::Value::Array(items.iter().map(to_serde).collect())
900 }
901 Projected::Record(fields) => {
902 let mut map = serde_json::Map::new();
903 for (name, value) in fields {
904 map.insert(name.clone(), to_serde(value));
905 }
906 serde_json::Value::Object(map)
907 }
908 Projected::Blob {
909 hash,
910 size,
911 media_type,
912 tainted,
913 } => serde_json::json!({
914 "$active.blob": {
915 "hash": format!("blake3:{}", crate::ir::hash_hex(hash)),
916 "size": size,
917 "media_type": media_type,
918 "tainted": tainted,
919 }
920 }),
921 Projected::Env(name) => serde_json::json!({ "$env": name }),
922 Projected::Expr(kind) => serde_json::json!({ "$expr": kind }),
923 }
924 }
925
926 pub fn provenance_json(prov: &Provenance, secrets: &[String]) -> serde_json::Value {
929 let text = |value: &str| serde_json::Value::from(redacted_value(value, secrets));
930 let mut map = serde_json::Map::new();
931 if let Some(producer) = &prov.producer {
932 map.insert("producer".into(), text(producer));
933 }
934 if prov.timestamp_ms != 0 {
935 map.insert(
936 "timestamp_ms".into(),
937 serde_json::Value::from(prov.timestamp_ms),
938 );
939 }
940 if let Some(model) = &prov.model {
941 map.insert("model".into(), text(model));
942 }
943 if let Some(tool) = &prov.tool {
944 map.insert("tool".into(), text(tool));
945 }
946 if let Some(parent) = prov.parent {
947 map.insert(
948 "parent".into(),
949 serde_json::Value::from(crate::ir::hash_hex(&parent)),
950 );
951 }
952 let mut entries = serde_json::Map::new();
953 for entry in &prov.entries {
954 let value = match &entry.value {
955 ProvValue::Text(t) => text(t),
956 ProvValue::Int(number) => serde_json::Value::from(*number),
957 ProvValue::Flag(flag) => serde_json::Value::from(*flag),
958 ProvValue::Hash(hash) => serde_json::Value::from(crate::ir::hash_hex(hash)),
959 };
960 entries.insert(redacted_key(&entry.key, secrets), value);
961 }
962 map.insert("entries".into(), serde_json::Value::Object(entries));
963 serde_json::Value::Object(map)
964 }
965
966 pub fn provenance_json_for(module: &InfinityModule) -> Option<serde_json::Value> {
968 let secrets = secret_literals(module);
969 module
970 .provenance()
971 .map(|prov| provenance_json(prov, &secrets))
972 }
973}
974
975#[cfg(feature = "json")]
976pub use projection::{
977 provenance_json, provenance_json_for, secret_literals, to_redacted_json,
978 to_redacted_json_pretty,
979};
980
981pub fn value_paths(module: &InfinityModule) -> HashMap<NodeId, String> {
987 let mut paths: HashMap<NodeId, String> = HashMap::new();
988 for (name, id) in module.bindings() {
989 paths.insert(*id, name.clone());
990 }
991 if let Some(root) = module.root() {
992 let mut stack = vec![(root, String::new())];
993 let mut expanded = std::collections::HashSet::new();
994 while let Some((id, path)) = stack.pop() {
995 if !expanded.insert(id) {
997 continue;
998 }
999 match module.get(id) {
1000 Some(Inode::Table { fields }) => {
1001 for (key, child) in fields {
1002 let child_path = if path.is_empty() {
1003 key.clone()
1004 } else {
1005 format!("{path}.{key}")
1006 };
1007 paths.entry(*child).or_insert_with(|| child_path.clone());
1008 stack.push((*child, child_path));
1009 }
1010 }
1011 Some(Inode::SchemaTable { fields, .. }) => {
1012 for (key, child) in fields {
1013 let child_path = if path.is_empty() {
1014 key.clone()
1015 } else {
1016 format!("{path}.{key}")
1017 };
1018 paths.entry(*child).or_insert_with(|| child_path.clone());
1019 stack.push((*child, child_path));
1020 }
1021 }
1022 _ => {}
1023 }
1024 }
1025 }
1026 paths
1027}
1028
1029pub fn node_label(module: &InfinityModule, id: NodeId) -> String {
1036 if let Some(path) = value_paths(module).get(&id) {
1037 return path.clone();
1038 }
1039 match module.get(id) {
1040 Some(node) => format!("{}[{}]", node.kind().name(), id.0),
1041 None => format!("node{}", id.0),
1042 }
1043}
1044
1045pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
1048 if let Some(root) = module.root() {
1049 let mut current = root;
1050 let mut found = true;
1051 for segment in path.split('.') {
1052 match module.get(current) {
1053 Some(Inode::Table { fields }) | Some(Inode::SchemaTable { fields, .. }) => {
1054 match fields.iter().find(|(name, _)| name == segment) {
1055 Some((_, child)) => current = *child,
1056 None => {
1057 found = false;
1058 break;
1059 }
1060 }
1061 }
1062 _ => {
1063 found = false;
1064 break;
1065 }
1066 }
1067 }
1068 if found {
1069 return Some(current);
1070 }
1071 }
1072 if !path.contains('.') {
1073 return module
1074 .bindings()
1075 .iter()
1076 .find(|(name, _)| name == path)
1077 .map(|(_, id)| *id);
1078 }
1079 None
1080}
1081
1082fn adjacency(module: &InfinityModule) -> (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) {
1083 let count = module.node_count();
1084 let mut deps = vec![Vec::new(); count];
1085 let mut dependents = vec![Vec::new(); count];
1086 for (index, node) in module.nodes().iter().enumerate() {
1087 for child in node.children() {
1088 if child.index() < count {
1089 deps[index].push(child);
1090 dependents[child.index()].push(NodeId(index as u32));
1091 }
1092 }
1093 }
1094 (deps, dependents)
1095}
1096
1097fn walk(adjacency: &[Vec<NodeId>], start: NodeId) -> Vec<NodeId> {
1098 let mut seen = HashSet::new();
1099 let mut out = Vec::new();
1100 let mut stack = vec![start];
1101 seen.insert(start);
1102 while let Some(id) = stack.pop() {
1103 for next in &adjacency[id.index()] {
1104 if seen.insert(*next) {
1105 out.push(*next);
1106 stack.push(*next);
1107 }
1108 }
1109 }
1110 out
1111}
1112
1113pub fn impact(module: &InfinityModule, id: NodeId) -> Vec<String> {
1118 let (_, dependents) = adjacency(module);
1119 let mut labels: Vec<String> = walk(&dependents, id)
1120 .into_iter()
1121 .map(|node| node_label(module, node))
1122 .collect();
1123 labels.sort();
1124 labels.dedup();
1125 labels
1126}
1127
1128pub fn why(module: &InfinityModule, id: NodeId) -> Vec<String> {
1130 let (deps, _) = adjacency(module);
1131 let mut labels: Vec<String> = walk(&deps, id)
1132 .into_iter()
1133 .map(|node| node_label(module, node))
1134 .collect();
1135 labels.sort();
1136 labels.dedup();
1137 labels
1138}
1139
1140pub fn dependents(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1142 let (_, dependents) = adjacency(module);
1143 let mut out = dependents.get(id.index()).cloned().unwrap_or_default();
1144 out.sort_unstable();
1145 out.dedup();
1146 out
1147}
1148
1149pub fn dependencies(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1151 let mut out = module
1152 .get(id)
1153 .map(|node| node.children())
1154 .unwrap_or_default();
1155 out.sort_unstable();
1156 out.dedup();
1157 out
1158}
1159
1160pub fn subgraph(module: &InfinityModule, roots: &[NodeId]) -> Vec<NodeId> {
1162 let (deps, _) = adjacency(module);
1163 let mut seen: HashSet<NodeId> = HashSet::new();
1164 let mut order = Vec::new();
1165 let mut queue: VecDeque<NodeId> = VecDeque::new();
1166 for root in roots {
1167 if module.get(*root).is_some() && seen.insert(*root) {
1168 order.push(*root);
1169 queue.push_back(*root);
1170 }
1171 }
1172 while let Some(node) = queue.pop_front() {
1173 for dep in &deps[node.index()] {
1174 if seen.insert(*dep) {
1175 order.push(*dep);
1176 queue.push_back(*dep);
1177 }
1178 }
1179 }
1180 order
1181}
1182
1183pub fn secret_tainted_paths(module: &InfinityModule) -> Vec<String> {
1186 let mut tainted: Vec<bool> = vec![false; module.node_count()];
1187 let order = subgraph(module, &root_ids(module));
1190 let mut changed = true;
1191 while changed {
1192 changed = false;
1193 for id in &order {
1194 let Some(node) = module.get(*id) else {
1195 continue;
1196 };
1197 let value = node.is_secret()
1198 || node
1199 .children()
1200 .iter()
1201 .any(|child| tainted.get(child.index()).copied().unwrap_or(false));
1202 if value && !tainted[id.index()] {
1203 tainted[id.index()] = true;
1204 changed = true;
1205 }
1206 }
1207 }
1208 let paths = value_paths(module);
1209 let mut out: Vec<String> = module
1210 .nodes()
1211 .iter()
1212 .enumerate()
1213 .filter(|(index, _)| tainted[*index])
1214 .filter_map(|(index, _)| paths.get(&NodeId(index as u32)).cloned())
1215 .collect();
1216 out.sort();
1217 out.dedup();
1218 out
1219}
1220
1221fn root_ids(module: &InfinityModule) -> Vec<NodeId> {
1222 let mut roots = Vec::new();
1223 if let Some(root) = module.root() {
1224 roots.push(root);
1225 }
1226 roots.extend(module.bindings().iter().map(|(_, id)| *id));
1227 roots
1228}
1229
1230pub fn provenance_lines(module: &InfinityModule) -> Vec<String> {
1233 let Some(prov) = module.provenance() else {
1234 return Vec::new();
1235 };
1236 let secrets: Vec<String> = module
1237 .nodes()
1238 .iter()
1239 .filter_map(|node| match node {
1240 Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
1241 _ => None,
1242 })
1243 .collect();
1244 let redact = |text: &str| -> String {
1245 if secrets
1246 .iter()
1247 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
1248 {
1249 SECRET_MARKER.to_string()
1250 } else {
1251 text.to_string()
1252 }
1253 };
1254 let mut lines = Vec::new();
1255 if let Some(producer) = &prov.producer {
1256 lines.push(format!("producer={}", redact(producer)));
1257 }
1258 if prov.timestamp_ms != 0 {
1259 lines.push(format!("timestamp_ms={}", prov.timestamp_ms));
1260 }
1261 if let Some(model) = &prov.model {
1262 lines.push(format!("model={}", redact(model)));
1263 }
1264 if let Some(tool) = &prov.tool {
1265 lines.push(format!("tool={}", redact(tool)));
1266 }
1267 if let Some(parent) = prov.parent {
1268 lines.push(format!("parent=blake3:{}", crate::ir::hash_hex(&parent)));
1269 }
1270 for entry in &prov.entries {
1271 let value = match &entry.value {
1272 ProvValue::Text(text) => redact(text),
1273 ProvValue::Int(number) => number.to_string(),
1274 ProvValue::Flag(flag) => flag.to_string(),
1275 ProvValue::Hash(hash) => format!("blake3:{}", crate::ir::hash_hex(hash)),
1276 };
1277 lines.push(format!("{}={value}", redacted_key(&entry.key, &secrets)));
1278 }
1279 lines
1280}
1281
1282pub fn intents(module: &InfinityModule, id: NodeId) -> Vec<String> {
1288 let secrets = secret_texts(module);
1289 module
1290 .annotations()
1291 .iter()
1292 .filter(|annotation| annotation.node == id)
1293 .filter_map(|annotation| match &annotation.kind {
1294 AnnotationKind::Intent(text) => Some(redacted_value(text, &secrets)),
1295 _ => None,
1296 })
1297 .collect()
1298}
1299
1300pub fn immutable_nodes(module: &InfinityModule) -> Vec<NodeId> {
1302 let mut out: Vec<NodeId> = module
1303 .annotations()
1304 .iter()
1305 .filter_map(|annotation| match &annotation.kind {
1306 AnnotationKind::AiImmutable(true) => Some(annotation.node),
1307 _ => None,
1308 })
1309 .collect();
1310 out.sort_unstable();
1311 out.dedup();
1312 out
1313}
1314
1315pub fn provenance_pairs(prov: &Provenance) -> Vec<&ProvEntry> {
1317 let mut entries: Vec<&ProvEntry> = prov.entries.iter().collect();
1318 entries.sort_by(|a, b| a.key.cmp(&b.key));
1319 entries
1320}
1321
1322#[cfg(feature = "active-core")]
1327pub fn from_active_source(source: &str, name: &str) -> Result<InfinityModule> {
1328 let core = active_core::parse(source, name)
1329 .map_err(|_| Error::Encode("Active source failed to parse"))?;
1330 from_active_core(&core)
1331}