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