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