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