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
670#[derive(Debug, Clone, PartialEq)]
676pub enum Projected {
677 Null,
679 Bool(bool),
681 Int(i64),
683 Float(f64),
685 Text(String),
687 DurationMs(u64),
689 Quantity(Box<Projected>, String),
691 Array(Vec<Projected>),
693 Record(Vec<(String, Projected)>),
695 Blob {
697 hash: [u8; 32],
699 size: u64,
701 media_type: String,
703 tainted: bool,
705 },
706 Env(String),
708 Expr(&'static str),
710}
711
712#[cfg(feature = "json")]
719pub(crate) fn project_root(module: &InfinityModule) -> Result<Projected> {
720 module.validate_structure(u32::MAX as u64, 4096)?;
721 let secrets = secret_texts(module);
722 build_projected(module, module.root(), &secrets, 0)
723}
724
725pub(crate) fn secret_table(module: &InfinityModule) -> Vec<String> {
727 secret_texts(module)
728}
729
730pub(crate) fn project_node_with(
732 module: &InfinityModule,
733 id: NodeId,
734 secrets: &[String],
735) -> Result<Projected> {
736 build_projected(module, Some(id), secrets, 0)
737}
738
739pub(crate) fn redacted_value(text: &str, secrets: &[String]) -> String {
743 if secrets
744 .iter()
745 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
746 {
747 SECRET_MARKER.to_string()
748 } else {
749 text.to_string()
750 }
751}
752
753pub(crate) fn first_entry_per_key(entries: &[ProvEntry]) -> Vec<&ProvEntry> {
759 let mut seen = std::collections::HashSet::new();
760 entries
761 .iter()
762 .filter(|entry| seen.insert(entry.key.as_str()))
763 .collect()
764}
765
766pub(crate) fn redacted_key(text: &str, secrets: &[String]) -> String {
770 redact_text(text, secrets)
771}
772
773fn resolves_to_secret(module: &InfinityModule, id: NodeId, depth: usize) -> bool {
778 if depth > PROJECTION_DEPTH {
779 return true;
781 }
782 match module.get(id) {
783 Some(Inode::Secret(_)) | Some(Inode::SecretRef { .. }) => true,
784 Some(Inode::Ref(inner)) => resolves_to_secret(module, *inner, depth + 1),
785 Some(Inode::Semantic { inner, .. }) => resolves_to_secret(module, *inner, depth + 1),
786 Some(node) => node.is_secret(),
787 None => false,
788 }
789}
790
791fn build_projected(
792 module: &InfinityModule,
793 id: Option<NodeId>,
794 secrets: &[String],
795 depth: usize,
796) -> Result<Projected> {
797 if depth > PROJECTION_DEPTH {
798 return Err(Error::Encode("projection nesting exceeds the limit"));
799 }
800 let value = match id.and_then(|id| module.get(id)) {
801 None => Projected::Null,
802 Some(node) => match node {
803 Inode::Null => Projected::Null,
804 Inode::Bool(flag) => Projected::Bool(*flag),
805 Inode::Int(number) => Projected::Int(*number),
806 Inode::Float(number) => Projected::Float(*number),
807 Inode::Str(text) => Projected::Text(redacted_value(text, secrets)),
808 Inode::Ident(text) => Projected::Text(redacted_key(text, secrets)),
809 Inode::DurationMs(ms) => Projected::DurationMs(*ms),
810 Inode::Quantity { value, unit } => Projected::Quantity(
811 Box::new(build_projected(module, Some(*value), secrets, depth + 1)?),
812 redacted_key(unit.name(), secrets),
813 ),
814 Inode::Array { elements } => Projected::Array(
815 elements
816 .iter()
817 .map(|id| build_projected(module, Some(*id), secrets, depth + 1))
818 .collect::<Result<Vec<_>>>()?,
819 ),
820 Inode::Table { fields } => {
821 let mut projected = Vec::with_capacity(fields.len());
822 for (name, id) in fields {
823 projected.push((
824 redacted_key(name, secrets),
825 build_projected(module, Some(*id), secrets, depth + 1)?,
826 ));
827 }
828 Projected::Record(projected)
829 }
830 Inode::Secret(_) | Inode::SecretRef { .. } => {
831 Projected::Text(SECRET_MARKER.to_string())
832 }
833 Inode::Ref(id) => build_projected(module, Some(*id), secrets, depth + 1)?,
834 Inode::Env { name, .. } => Projected::Env(redacted_key(name, secrets)),
835 Inode::Interpolate { parts } => {
836 let mut text = String::new();
837 let mut tainted = false;
838 for part in parts {
839 match part {
840 InterpPart::Literal(literal) => text.push_str(literal),
841 InterpPart::Expr(id) => match id.index() {
842 _ if resolves_to_secret(module, *id, 0) => tainted = true,
843 _ => text.push_str("${}"),
844 },
845 }
846 }
847 if tainted {
848 Projected::Text(SECRET_MARKER.to_string())
849 } else {
850 Projected::Text(redacted_value(&text, secrets))
851 }
852 }
853 Inode::Binary { .. }
854 | Inode::Unary { .. }
855 | Inode::Constrained { .. }
856 | Inode::Patch(_)
857 | Inode::SchemaField(_)
858 | Inode::SchemaTable { .. }
859 | Inode::SchemaArray { .. }
860 | Inode::ContentAddressed { .. } => {
861 Projected::Expr(crate::view::node_kind_name(node.kind_code()))
862 }
863 Inode::Semantic { inner, .. } => {
864 build_projected(module, Some(*inner), secrets, depth + 1)?
865 }
866 Inode::Blob {
867 hash,
868 size,
869 tainted,
870 media_type,
871 ..
872 } => Projected::Blob {
873 hash: *hash,
874 size: *size,
875 media_type: if *tainted {
876 SECRET_MARKER.to_string()
877 } else {
878 redacted_key(media_type, secrets)
879 },
880 tainted: *tainted,
881 },
882 },
883 };
884 Ok(value)
885}
886
887#[cfg(feature = "json")]
892mod projection {
893 use super::*;
894
895 pub fn to_redacted_json(module: &InfinityModule) -> Result<String> {
903 let value = to_serde(&project_root(module)?);
904 serde_json::to_string(&value).map_err(|_| Error::Encode("json projection failed"))
905 }
906
907 pub fn to_redacted_json_pretty(module: &InfinityModule) -> Result<String> {
909 let value = to_serde(&project_root(module)?);
910 serde_json::to_string_pretty(&value).map_err(|_| Error::Encode("json projection failed"))
911 }
912
913 pub(crate) fn secret_literals(module: &InfinityModule) -> Vec<String> {
918 secret_texts(module)
919 }
920
921 fn to_serde(value: &Projected) -> serde_json::Value {
923 match value {
924 Projected::Null => serde_json::Value::Null,
925 Projected::Bool(flag) => serde_json::Value::from(*flag),
926 Projected::Int(number) => serde_json::Value::from(*number),
927 Projected::Float(number) => serde_json::Value::from(*number),
928 Projected::Text(text) => serde_json::Value::from(text.clone()),
929 Projected::DurationMs(ms) => serde_json::json!({ "ms": ms }),
930 Projected::Quantity(value, unit) => serde_json::json!({
931 "value": to_serde(value),
932 "unit": unit,
933 }),
934 Projected::Array(items) => {
935 serde_json::Value::Array(items.iter().map(to_serde).collect())
936 }
937 Projected::Record(fields) => {
938 let mut map = serde_json::Map::new();
939 for (name, value) in fields {
940 map.insert(name.clone(), to_serde(value));
941 }
942 serde_json::Value::Object(map)
943 }
944 Projected::Blob {
945 hash,
946 size,
947 media_type,
948 tainted,
949 } => serde_json::json!({
950 "$active.blob": {
951 "hash": format!("blake3:{}", crate::ir::hash_hex(hash)),
952 "size": size,
953 "media_type": media_type,
954 "tainted": tainted,
955 }
956 }),
957 Projected::Env(name) => serde_json::json!({ "$env": name }),
958 Projected::Expr(kind) => serde_json::json!({ "$expr": kind }),
959 }
960 }
961
962 pub fn provenance_json(prov: &Provenance, secrets: &[String]) -> serde_json::Value {
965 let text = |value: &str| serde_json::Value::from(redacted_value(value, secrets));
966 let mut map = serde_json::Map::new();
967 if let Some(producer) = &prov.producer {
968 map.insert("producer".into(), text(producer));
969 }
970 if prov.timestamp_ms != 0 {
971 map.insert(
972 "timestamp_ms".into(),
973 serde_json::Value::from(prov.timestamp_ms),
974 );
975 }
976 if let Some(model) = &prov.model {
977 map.insert("model".into(), text(model));
978 }
979 if let Some(tool) = &prov.tool {
980 map.insert("tool".into(), text(tool));
981 }
982 if let Some(parent) = prov.parent {
983 map.insert(
984 "parent".into(),
985 serde_json::Value::from(crate::ir::hash_hex(&parent)),
986 );
987 }
988 let mut entries = serde_json::Map::new();
989 for entry in first_entry_per_key(&prov.entries) {
990 let value = match &entry.value {
991 ProvValue::Text(t) => text(t),
992 ProvValue::Int(number) => serde_json::Value::from(*number),
993 ProvValue::Flag(flag) => serde_json::Value::from(*flag),
994 ProvValue::Hash(hash) => serde_json::Value::from(crate::ir::hash_hex(hash)),
995 };
996 entries.insert(redacted_key(&entry.key, secrets), value);
997 }
998 map.insert("entries".into(), serde_json::Value::Object(entries));
999 serde_json::Value::Object(map)
1000 }
1001
1002 pub fn provenance_json_for(module: &InfinityModule) -> Option<serde_json::Value> {
1004 let secrets = secret_literals(module);
1005 module
1006 .provenance()
1007 .map(|prov| provenance_json(prov, &secrets))
1008 }
1009}
1010
1011#[cfg(feature = "json")]
1012pub use projection::{
1013 provenance_json, provenance_json_for, to_redacted_json, to_redacted_json_pretty,
1014};
1015
1016pub fn value_paths(module: &InfinityModule) -> HashMap<NodeId, String> {
1022 let secrets = secret_texts(module);
1025 let mut paths: HashMap<NodeId, String> = HashMap::new();
1026 for (name, id) in module.bindings() {
1027 paths.insert(*id, redacted_key(name, &secrets));
1028 }
1029 if let Some(root) = module.root() {
1030 let mut stack = vec![(root, String::new())];
1031 let mut expanded = std::collections::HashSet::new();
1032 while let Some((id, path)) = stack.pop() {
1033 if !expanded.insert(id) {
1035 continue;
1036 }
1037 match module.get(id) {
1038 Some(Inode::Table { fields }) => {
1039 for (key, child) in fields {
1040 let key = redacted_key(key, &secrets);
1041 let child_path = if path.is_empty() {
1042 key
1043 } else {
1044 format!("{path}.{key}")
1045 };
1046 paths.entry(*child).or_insert_with(|| child_path.clone());
1047 stack.push((*child, child_path));
1048 }
1049 }
1050 Some(Inode::SchemaTable { fields, .. }) => {
1051 for (key, child) in fields {
1052 let key = redacted_key(key, &secrets);
1053 let child_path = if path.is_empty() {
1054 key
1055 } else {
1056 format!("{path}.{key}")
1057 };
1058 paths.entry(*child).or_insert_with(|| child_path.clone());
1059 stack.push((*child, child_path));
1060 }
1061 }
1062 _ => {}
1063 }
1064 }
1065 }
1066 paths
1067}
1068
1069pub fn node_label(module: &InfinityModule, id: NodeId) -> String {
1076 if let Some(path) = value_paths(module).get(&id) {
1077 return path.clone();
1078 }
1079 match module.get(id) {
1080 Some(node) => format!("{}[{}]", node.kind().name(), id.0),
1081 None => format!("node{}", id.0),
1082 }
1083}
1084
1085pub fn resolve_path(module: &InfinityModule, path: &str) -> Option<NodeId> {
1088 if let Some(root) = module.root() {
1089 let mut current = root;
1090 let mut found = true;
1091 for segment in path.split('.') {
1092 match module.get(current) {
1093 Some(Inode::Table { fields }) | Some(Inode::SchemaTable { fields, .. }) => {
1094 match fields.iter().find(|(name, _)| name == segment) {
1095 Some((_, child)) => current = *child,
1096 None => {
1097 found = false;
1098 break;
1099 }
1100 }
1101 }
1102 _ => {
1103 found = false;
1104 break;
1105 }
1106 }
1107 }
1108 if found {
1109 return Some(current);
1110 }
1111 }
1112 if !path.contains('.') {
1113 return module
1114 .bindings()
1115 .iter()
1116 .find(|(name, _)| name == path)
1117 .map(|(_, id)| *id);
1118 }
1119 None
1120}
1121
1122fn adjacency(module: &InfinityModule) -> (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) {
1123 let count = module.node_count();
1124 let mut deps = vec![Vec::new(); count];
1125 let mut dependents = vec![Vec::new(); count];
1126 for (index, node) in module.nodes().iter().enumerate() {
1127 for child in node.children() {
1128 if child.index() < count {
1129 deps[index].push(child);
1130 dependents[child.index()].push(NodeId(index as u32));
1131 }
1132 }
1133 }
1134 (deps, dependents)
1135}
1136
1137fn walk(adjacency: &[Vec<NodeId>], start: NodeId) -> Vec<NodeId> {
1138 let mut seen = HashSet::new();
1139 let mut out = Vec::new();
1140 let mut stack = vec![start];
1141 seen.insert(start);
1142 while let Some(id) = stack.pop() {
1143 for next in &adjacency[id.index()] {
1144 if seen.insert(*next) {
1145 out.push(*next);
1146 stack.push(*next);
1147 }
1148 }
1149 }
1150 out
1151}
1152
1153pub fn impact(module: &InfinityModule, id: NodeId) -> Vec<String> {
1158 let (_, dependents) = adjacency(module);
1159 let mut labels: Vec<String> = walk(&dependents, id)
1160 .into_iter()
1161 .map(|node| node_label(module, node))
1162 .collect();
1163 labels.sort();
1164 labels.dedup();
1165 labels
1166}
1167
1168pub fn why(module: &InfinityModule, id: NodeId) -> Vec<String> {
1170 let (deps, _) = adjacency(module);
1171 let mut labels: Vec<String> = walk(&deps, id)
1172 .into_iter()
1173 .map(|node| node_label(module, node))
1174 .collect();
1175 labels.sort();
1176 labels.dedup();
1177 labels
1178}
1179
1180pub fn dependents(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1182 let (_, dependents) = adjacency(module);
1183 let mut out = dependents.get(id.index()).cloned().unwrap_or_default();
1184 out.sort_unstable();
1185 out.dedup();
1186 out
1187}
1188
1189pub fn dependencies(module: &InfinityModule, id: NodeId) -> Vec<NodeId> {
1191 let mut out = module
1192 .get(id)
1193 .map(|node| node.children())
1194 .unwrap_or_default();
1195 out.sort_unstable();
1196 out.dedup();
1197 out
1198}
1199
1200pub fn subgraph(module: &InfinityModule, roots: &[NodeId]) -> Vec<NodeId> {
1202 let (deps, _) = adjacency(module);
1203 let mut seen: HashSet<NodeId> = HashSet::new();
1204 let mut order = Vec::new();
1205 let mut queue: VecDeque<NodeId> = VecDeque::new();
1206 for root in roots {
1207 if module.get(*root).is_some() && seen.insert(*root) {
1208 order.push(*root);
1209 queue.push_back(*root);
1210 }
1211 }
1212 while let Some(node) = queue.pop_front() {
1213 for dep in &deps[node.index()] {
1214 if seen.insert(*dep) {
1215 order.push(*dep);
1216 queue.push_back(*dep);
1217 }
1218 }
1219 }
1220 order
1221}
1222
1223pub fn secret_tainted_paths(module: &InfinityModule) -> Vec<String> {
1226 let mut tainted: Vec<bool> = vec![false; module.node_count()];
1227 let order = subgraph(module, &root_ids(module));
1230 let mut changed = true;
1231 while changed {
1232 changed = false;
1233 for id in &order {
1234 let Some(node) = module.get(*id) else {
1235 continue;
1236 };
1237 let value = node.is_secret()
1238 || node
1239 .children()
1240 .iter()
1241 .any(|child| tainted.get(child.index()).copied().unwrap_or(false));
1242 if value && !tainted[id.index()] {
1243 tainted[id.index()] = true;
1244 changed = true;
1245 }
1246 }
1247 }
1248 let paths = value_paths(module);
1249 let mut out: Vec<String> = module
1250 .nodes()
1251 .iter()
1252 .enumerate()
1253 .filter(|(index, _)| tainted[*index])
1254 .filter_map(|(index, _)| paths.get(&NodeId(index as u32)).cloned())
1255 .collect();
1256 out.sort();
1257 out.dedup();
1258 out
1259}
1260
1261fn root_ids(module: &InfinityModule) -> Vec<NodeId> {
1262 let mut roots = Vec::new();
1263 if let Some(root) = module.root() {
1264 roots.push(root);
1265 }
1266 roots.extend(module.bindings().iter().map(|(_, id)| *id));
1267 roots
1268}
1269
1270pub fn provenance_lines(module: &InfinityModule) -> Vec<String> {
1273 let Some(prov) = module.provenance() else {
1274 return Vec::new();
1275 };
1276 let secrets: Vec<String> = module
1277 .nodes()
1278 .iter()
1279 .filter_map(|node| match node {
1280 Inode::Secret(secret) => Some(String::from_utf8_lossy(secret.expose()).into_owned()),
1281 _ => None,
1282 })
1283 .collect();
1284 let redact = |text: &str| -> String {
1285 if secrets
1286 .iter()
1287 .any(|secret| !secret.is_empty() && text.contains(secret.as_str()))
1288 {
1289 SECRET_MARKER.to_string()
1290 } else {
1291 text.to_string()
1292 }
1293 };
1294 let mut lines = Vec::new();
1295 if let Some(producer) = &prov.producer {
1296 lines.push(format!("producer={}", redact(producer)));
1297 }
1298 if prov.timestamp_ms != 0 {
1299 lines.push(format!("timestamp_ms={}", prov.timestamp_ms));
1300 }
1301 if let Some(model) = &prov.model {
1302 lines.push(format!("model={}", redact(model)));
1303 }
1304 if let Some(tool) = &prov.tool {
1305 lines.push(format!("tool={}", redact(tool)));
1306 }
1307 if let Some(parent) = prov.parent {
1308 lines.push(format!("parent=blake3:{}", crate::ir::hash_hex(&parent)));
1309 }
1310 for entry in first_entry_per_key(&prov.entries) {
1311 let value = match &entry.value {
1312 ProvValue::Text(text) => redacted_value(text, &secrets),
1313 ProvValue::Int(number) => number.to_string(),
1314 ProvValue::Flag(flag) => flag.to_string(),
1315 ProvValue::Hash(hash) => format!("blake3:{}", crate::ir::hash_hex(hash)),
1316 };
1317 lines.push(format!("{}={value}", redacted_key(&entry.key, &secrets)));
1318 }
1319 lines
1320}
1321
1322pub fn intents(module: &InfinityModule, id: NodeId) -> Vec<String> {
1328 let secrets = secret_texts(module);
1329 module
1330 .annotations()
1331 .iter()
1332 .filter(|annotation| annotation.node == id)
1333 .filter_map(|annotation| match &annotation.kind {
1334 AnnotationKind::Intent(text) => Some(redact_text(text, &secrets)),
1335 _ => None,
1336 })
1337 .collect()
1338}
1339
1340pub fn immutable_nodes(module: &InfinityModule) -> Vec<NodeId> {
1342 let mut out: Vec<NodeId> = module
1343 .annotations()
1344 .iter()
1345 .filter_map(|annotation| match &annotation.kind {
1346 AnnotationKind::AiImmutable(true) => Some(annotation.node),
1347 _ => None,
1348 })
1349 .collect();
1350 out.sort_unstable();
1351 out.dedup();
1352 out
1353}
1354
1355pub fn provenance_pairs(prov: &Provenance) -> Vec<&ProvEntry> {
1362 let mut entries: Vec<&ProvEntry> = prov.entries.iter().collect();
1363 entries.sort_by(|a, b| a.key.cmp(&b.key));
1364 entries
1365}
1366
1367#[cfg(feature = "active-core")]
1372pub fn from_active_source(source: &str, name: &str) -> Result<InfinityModule> {
1373 let core = active_core::parse(source, name)
1374 .map_err(|_| Error::Encode("Active source failed to parse"))?;
1375 from_active_core(&core)
1376}