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