1use std::collections::{HashMap, HashSet};
31
32use crate::config::{EdgeConfig, NodeConfig, PolicyConfig, SupernodeConfig};
33
34pub(crate) const BOUNDARY_TYPES: [&str; 3] = ["input", "output", "error"];
37
38pub(crate) fn split_endpoint(s: &str) -> (&str, &str) {
42 match s.rfind('.') {
43 Some(i) => (&s[..i], &s[i + 1..]),
44 None => (s, "out"),
45 }
46}
47
48pub(crate) fn port_for_output_boundary(boundary_id: &str) -> &str {
53 if boundary_id == "output" {
54 "success"
55 } else {
56 boundary_id
57 }
58}
59
60pub fn expand_policy(
89 policy: &PolicyConfig,
90 supernodes: &[SupernodeConfig],
91) -> Result<PolicyConfig, String> {
92 let instances: Vec<&NodeConfig> = policy
93 .nodes
94 .iter()
95 .filter(|n| n.node_type == "supernode")
96 .collect();
97 if instances.is_empty() {
98 return Ok(policy.clone());
99 }
100
101 let by_name: HashMap<&str, &SupernodeConfig> =
102 supernodes.iter().map(|s| (s.name.as_str(), s)).collect();
103
104 struct Splice<'a> {
106 def: &'a SupernodeConfig,
107 boundary_map: HashMap<String, String>,
109 input_node_id: String,
111 entry: Option<String>,
113 pass_through_boundary: Option<String>,
115 exit_to: HashMap<String, String>,
119 }
120
121 let mut splices: HashMap<&str, Splice> = HashMap::new();
122 for inst in &instances {
123 let name = inst
124 .config
125 .get("name")
126 .and_then(|v| v.as_str())
127 .ok_or_else(|| {
128 format!(
129 "policy '{}': supernode node '{}' is missing config.name",
130 policy.name, inst.id
131 )
132 })?;
133 let def = *by_name.get(name).ok_or_else(|| {
134 format!(
135 "policy '{}': node '{}' references unknown supernode '{}'",
136 policy.name, inst.id, name
137 )
138 })?;
139
140 if def.nodes.iter().any(|n| n.node_type == "supernode") {
142 return Err(format!(
143 "policy '{}': supernode '{}' contains nested supernode; nesting not supported in V1",
144 policy.name, def.name
145 ));
146 }
147
148 let mut boundary_map = HashMap::new();
150 for n in &def.nodes {
151 if BOUNDARY_TYPES.contains(&n.node_type.as_str()) {
152 boundary_map.insert(n.id.clone(), n.node_type.clone());
153 }
154 }
155
156 let input_node_id = boundary_map
158 .iter()
159 .find(|(_, ty)| ty.as_str() == "input")
160 .map(|(id, _)| id.clone())
161 .ok_or_else(|| format!("supernode '{}' has no input boundary node", def.name))?;
162
163 let entry_edge = def
165 .edges
166 .iter()
167 .find(|e| split_endpoint(&e.from).0 == input_node_id.as_str())
168 .ok_or_else(|| {
169 format!(
170 "supernode '{}' has no edge from input node '{}'",
171 def.name,
172 input_node_id.as_str()
173 )
174 })?;
175 let (entry_target, _) = split_endpoint(&entry_edge.to);
176
177 let (entry, pass_through_boundary) = if boundary_map.contains_key(entry_target) {
179 (None, Some(entry_target.to_string()))
180 } else {
181 (Some(format!("{}/{}.in", inst.id, entry_target)), None)
182 };
183
184 let output_ids: Vec<String> = def
185 .nodes
186 .iter()
187 .filter(|n| n.node_type == "output")
188 .map(|n| n.id.clone())
189 .collect();
190 let error_ids: Vec<String> = def
191 .nodes
192 .iter()
193 .filter(|n| n.node_type == "error")
194 .map(|n| n.id.clone())
195 .collect();
196
197 let mut exit_to: HashMap<String, String> = HashMap::new();
198 for e in &policy.edges {
199 let (from_node, from_port) = split_endpoint(&e.from);
200 if from_node != inst.id {
201 continue;
202 }
203 let port = if from_port == "out" {
204 "success"
205 } else {
206 from_port
207 };
208 let boundary_id = output_ids
209 .iter()
210 .find(|id| port_for_output_boundary(id) == port)
211 .or_else(|| error_ids.iter().find(|id| id.as_str() == port))
212 .cloned();
213 let Some(bid) = boundary_id else {
214 let mut exposed: Vec<&str> = output_ids
215 .iter()
216 .map(|id| port_for_output_boundary(id))
217 .collect();
218 exposed.extend(error_ids.iter().map(|id| id.as_str()));
219 return Err(format!(
220 "policy '{}': unknown port '{}' on supernode instance '{}' — \
221 supernode '{}' exposes: {}",
222 policy.name,
223 from_port,
224 inst.id,
225 def.name,
226 exposed.join(", ")
227 ));
228 };
229 if exit_to.insert(bid, e.to.clone()).is_some() {
230 return Err(format!(
231 "policy '{}': duplicate edge from supernode instance '{}' port '{}' — \
232 each instance port accepts one edge",
233 policy.name, inst.id, port
234 ));
235 }
236 }
237
238 for oid in &output_ids {
242 if !exit_to.contains_key(oid.as_str()) {
243 let port = port_for_output_boundary(oid);
244 return Err(format!(
245 "policy '{}': output port '{}' of supernode instance '{}' must be \
246 wired — add an edge from '{}.{}'",
247 policy.name, port, inst.id, inst.id, port
248 ));
249 }
250 }
251
252 splices.insert(
253 inst.id.as_str(),
254 Splice {
255 def,
256 boundary_map,
257 input_node_id,
258 entry,
259 pass_through_boundary,
260 exit_to,
261 },
262 );
263 }
264
265 fn resolve_target(
272 splices: &HashMap<&str, Splice>,
273 target: &str,
274 ) -> Result<Option<String>, String> {
275 let mut current = target.to_string();
276 let mut path: Vec<String> = Vec::new();
279 loop {
280 let (target_node, _) = split_endpoint(¤t);
281 let s = match splices.get(target_node) {
282 Some(s) => s,
283 None => return Ok(Some(current)),
284 };
285 if let Some(entry) = &s.entry {
286 return Ok(Some(entry.clone()));
288 }
289 let pass_boundary = match &s.pass_through_boundary {
290 Some(b) => b,
291 None => return Ok(Some(current)),
292 };
293 if path.iter().any(|p| p == target_node) {
294 path.push(target_node.to_string());
295 return Err(format!(
296 "supernode pass-through cycle: {}",
297 path.join(" -> ")
298 ));
299 }
300 path.push(target_node.to_string());
301 match s.exit_to.get(pass_boundary.as_str()) {
302 Some(t) => current = t.clone(),
303 None => return Ok(None), }
305 }
306 }
307
308 for (id, s) in &splices {
311 if s.pass_through_boundary.is_some() {
312 resolve_target(&splices, &format!("{id}.in"))
313 .map_err(|err| format!("policy '{}': {}", policy.name, err))?;
314 }
315 }
316
317 let mut nodes: Vec<NodeConfig> = policy
319 .nodes
320 .iter()
321 .filter(|n| n.node_type != "supernode")
322 .cloned()
323 .collect();
324
325 let mut edges: Vec<EdgeConfig> = Vec::new();
329 for e in &policy.edges {
330 let (from_node, _) = split_endpoint(&e.from);
331 if splices.contains_key(from_node) {
332 continue;
333 }
334 let (to_node, _) = split_endpoint(&e.to);
335 if splices.contains_key(to_node) {
336 if let Some(resolved) = resolve_target(&splices, &e.to)
337 .map_err(|err| format!("policy '{}': {}", policy.name, err))?
338 {
339 edges.push(EdgeConfig {
340 from: e.from.clone(),
341 to: resolved,
342 });
343 }
344 } else {
345 edges.push(e.clone());
346 }
347 }
348
349 for inst in &instances {
350 let s = &splices[inst.id.as_str()];
351 if s.pass_through_boundary.is_some() {
352 continue;
354 }
355
356 let prefix = |id: &str| format!("{}/{}", inst.id, id);
357
358 let handled_errors: HashSet<&str> = s
360 .def
361 .edges
362 .iter()
363 .filter(|e| split_endpoint(&e.from).1 == "error")
364 .map(|e| split_endpoint(&e.from).0)
365 .collect();
366
367 for n in &s.def.nodes {
368 if BOUNDARY_TYPES.contains(&n.node_type.as_str()) {
369 continue;
370 }
371 let mut inlined = n.clone();
372 inlined.id = prefix(&n.id);
373 inlined.position = None;
374 nodes.push(inlined);
375 }
376
377 for e in &s.def.edges {
378 let (from_node, from_port) = split_endpoint(&e.from);
379 let (to_node, to_port) = split_endpoint(&e.to);
380 if from_node == s.input_node_id.as_str() {
381 continue; }
383 let from = format!("{}.{}", prefix(from_node), from_port);
384
385 let to_is_boundary = matches!(
387 s.boundary_map.get(to_node).map(|t| t.as_str()),
388 Some("output") | Some("error")
389 );
390 if to_is_boundary {
391 if let Some(t) = s.exit_to.get(to_node) {
392 if let Some(resolved) = resolve_target(&splices, t)
393 .map_err(|err| format!("policy '{}': {}", policy.name, err))?
394 {
395 edges.push(EdgeConfig { from, to: resolved });
396 }
397 }
398 } else {
401 edges.push(EdgeConfig {
402 from,
403 to: format!("{}.{}", prefix(to_node), to_port),
404 });
405 }
406 }
407
408 if let Some(t) = s.exit_to.get("error") {
415 if let Some(resolved) = resolve_target(&splices, t)
416 .map_err(|err| format!("policy '{}': {}", policy.name, err))?
417 {
418 for n in &s.def.nodes {
419 if BOUNDARY_TYPES.contains(&n.node_type.as_str())
420 || handled_errors.contains(n.id.as_str())
421 {
422 continue;
423 }
424 edges.push(EdgeConfig {
425 from: format!("{}.error", prefix(&n.id)),
426 to: resolved.clone(),
427 });
428 }
429 }
430 }
431 }
432
433 Ok(PolicyConfig {
434 name: policy.name.clone(),
435 error_handler: policy.error_handler.clone(),
436 nodes,
437 edges,
438 })
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use std::collections::HashMap as Map;
445
446 fn node(id: &str, ty: &str) -> NodeConfig {
447 NodeConfig {
448 id: id.into(),
449 node_type: ty.into(),
450 config: Map::new(),
451 config_ref: None,
452 position: None,
453 }
454 }
455
456 fn supernode_instance(id: &str, name: &str) -> NodeConfig {
457 let mut n = node(id, "supernode");
458 n.config.insert("name".into(), serde_json::json!(name));
459 n
460 }
461
462 fn edge(from: &str, to: &str) -> EdgeConfig {
463 EdgeConfig {
464 from: from.into(),
465 to: to.into(),
466 }
467 }
468
469 fn secured_call() -> SupernodeConfig {
472 SupernodeConfig {
473 name: "secured-call".into(),
474 description: None,
475 nodes: vec![
476 node("input", "input"),
477 node("output", "output"),
478 node("error", "error"),
479 node("auth", "key-auth"),
480 node("up", "upstream"),
481 ],
482 edges: vec![
483 edge("input.out", "auth.in"),
484 edge("auth.success", "up.in"),
485 edge("auth.error", "error.in"),
486 edge("up.success", "output.in"),
487 ],
488 }
489 }
490
491 fn policy_using(instance: &str) -> PolicyConfig {
492 PolicyConfig {
493 name: "p".into(),
494 error_handler: None,
495 nodes: vec![
496 node("listener", "listener"),
497 supernode_instance(instance, "secured-call"),
498 node("eh", "error-handler"),
499 node("client", "client"),
500 ],
501 edges: vec![
502 edge("listener.out", &format!("{instance}.in")),
503 edge(&format!("{instance}.success"), "client.in"),
504 edge(&format!("{instance}.error"), "eh.in"),
505 edge("eh.success", "client.in"),
506 ],
507 }
508 }
509
510 fn edge_set(p: &PolicyConfig) -> Vec<String> {
511 let mut v: Vec<String> = p
512 .edges
513 .iter()
514 .map(|e| format!("{}->{}", e.from, e.to))
515 .collect();
516 v.sort();
517 v
518 }
519
520 #[test]
521 fn test_policy_without_instances_is_unchanged() {
522 let p = PolicyConfig {
523 name: "plain".into(),
524 error_handler: None,
525 nodes: vec![node("listener", "listener"), node("client", "client")],
526 edges: vec![edge("listener.out", "client.in")],
527 };
528 let out = expand_policy(&p, &[secured_call()]).unwrap();
529 assert_eq!(out.nodes.len(), 2);
530 assert_eq!(edge_set(&out), vec!["listener.out->client.in"]);
531 }
532
533 #[test]
534 fn test_happy_path_inlines_and_splices() {
535 let out = expand_policy(&policy_using("sec"), &[secured_call()]).unwrap();
536
537 let ids: HashSet<&str> = out.nodes.iter().map(|n| n.id.as_str()).collect();
538 assert!(ids.contains("sec/auth") && ids.contains("sec/up"));
539 assert!(!ids.contains("sec"), "instance node must be removed");
540 assert!(
541 !out.nodes
542 .iter()
543 .any(|n| BOUNDARY_TYPES.contains(&n.node_type.as_str())),
544 "boundary pseudo-nodes must not leak into the expanded policy"
545 );
546
547 assert_eq!(
548 edge_set(&out),
549 vec![
550 "eh.success->client.in",
551 "listener.out->sec/auth.in", "sec/auth.error->eh.in", "sec/auth.success->sec/up.in", "sec/up.error->eh.in", "sec/up.success->client.in", ]
557 );
558 }
559
560 #[test]
563 fn test_unwired_success_exit_is_rejected() {
564 let mut p = policy_using("sec");
565 p.edges.retain(|e| !e.from.starts_with("sec.")); let err = expand_policy(&p, &[secured_call()]).unwrap_err();
567 assert!(
568 err.contains("output port 'success' of supernode instance 'sec' must be wired"),
569 "got: {err}"
570 );
571 }
572
573 #[test]
576 fn test_unwired_error_exit_drops_edges() {
577 let mut p = policy_using("sec");
578 p.edges.retain(|e| e.from != "sec.error"); p.edges.retain(|e| !e.from.starts_with("eh.")); p.nodes.retain(|n| n.id != "eh"); let out = expand_policy(&p, &[secured_call()]).unwrap();
582 assert!(!out
583 .edges
584 .iter()
585 .any(|e| e.from.starts_with("sec/auth.error")));
586 assert!(!out.edges.iter().any(|e| e.from.starts_with("sec/up.error")));
587 assert!(out
588 .edges
589 .iter()
590 .any(|e| e.from == "sec/up.success" && e.to == "client.in"));
591 }
592
593 #[test]
594 fn test_two_instances_of_same_supernode_get_distinct_namespaces() {
595 let p = PolicyConfig {
596 name: "p2".into(),
597 error_handler: None,
598 nodes: vec![
599 node("listener", "listener"),
600 supernode_instance("a", "secured-call"),
601 supernode_instance("b", "secured-call"),
602 node("client", "client"),
603 ],
604 edges: vec![
605 edge("listener.out", "a.in"),
606 edge("a.success", "b.in"),
607 edge("b.success", "client.in"),
608 ],
609 };
610 let out = expand_policy(&p, &[secured_call()]).unwrap();
611 let ids: HashSet<&str> = out.nodes.iter().map(|n| n.id.as_str()).collect();
612 for id in ["a/auth", "a/up", "b/auth", "b/up"] {
613 assert!(ids.contains(id), "missing {id}");
614 }
615 assert!(out
617 .edges
618 .iter()
619 .any(|e| e.from == "a/up.success" && e.to == "b/auth.in"));
620 }
621
622 #[test]
623 fn test_unknown_supernode_is_an_error() {
624 let p = PolicyConfig {
625 name: "p".into(),
626 error_handler: None,
627 nodes: vec![
628 node("listener", "listener"),
629 supernode_instance("sec", "nope"),
630 node("client", "client"),
631 ],
632 edges: vec![
633 edge("listener.out", "sec.in"),
634 edge("sec.success", "client.in"),
635 ],
636 };
637 let err = expand_policy(&p, &[secured_call()]).unwrap_err();
638 assert!(err.contains("unknown supernode 'nope'"), "got: {err}");
639 assert!(err.contains("'sec'"), "got: {err}");
640 }
641
642 #[test]
643 fn test_missing_config_name_is_an_error() {
644 let p = PolicyConfig {
645 name: "p".into(),
646 error_handler: None,
647 nodes: vec![
648 node("listener", "listener"),
649 node("sec", "supernode"), node("client", "client"),
651 ],
652 edges: vec![
653 edge("listener.out", "sec.in"),
654 edge("sec.success", "client.in"),
655 ],
656 };
657 let err = expand_policy(&p, &[secured_call()]).unwrap_err();
658 assert!(err.contains("missing config.name"), "got: {err}");
659 }
660
661 #[test]
663 fn test_inner_positions_are_dropped() {
664 let mut def = secured_call();
665 for n in &mut def.nodes {
666 n.position = Some(crate::config::Position { x: 1.0, y: 2.0 });
667 }
668 let out = expand_policy(&policy_using("sec"), &[def]).unwrap();
669 assert!(out
670 .nodes
671 .iter()
672 .filter(|n| n.id.starts_with("sec/"))
673 .all(|n| n.position.is_none()));
674 }
675
676 fn identity_supernode() -> SupernodeConfig {
679 SupernodeConfig {
680 name: "identity".into(),
681 description: None,
682 nodes: vec![
683 node("input", "input"),
684 node("output", "output"),
685 node("error", "error"),
686 ],
687 edges: vec![edge("input.out", "output.in")],
688 }
689 }
690
691 #[test]
692 fn test_pass_through_identity_with_wired_outer_success() {
693 let p = PolicyConfig {
694 name: "p".into(),
695 error_handler: None,
696 nodes: vec![
697 node("listener", "listener"),
698 supernode_instance("pass", "identity"),
699 node("client", "client"),
700 ],
701 edges: vec![
702 edge("listener.out", "pass.in"),
703 edge("pass.success", "client.in"),
704 ],
705 };
706 let out = expand_policy(&p, &[identity_supernode()]).unwrap();
707 assert!(!out.nodes.iter().any(|n| n.id.contains("pass")));
709 assert!(out
711 .edges
712 .iter()
713 .any(|e| e.from == "listener.out" && e.to == "client.in"));
714 }
715
716 #[test]
717 fn test_pass_through_identity_with_unwired_outer_success_is_rejected() {
718 let p = PolicyConfig {
719 name: "p".into(),
720 error_handler: None,
721 nodes: vec![
722 node("listener", "listener"),
723 supernode_instance("pass", "identity"),
724 node("client", "client"),
725 ],
726 edges: vec![
727 edge("listener.out", "pass.in"),
728 edge("pass.error", "client.in"),
729 ],
730 };
731 let err = expand_policy(&p, &[identity_supernode()]).unwrap_err();
732 assert!(
733 err.contains("output port 'success' of supernode instance 'pass'"),
734 "got: {err}"
735 );
736 }
737
738 #[test]
739 fn test_pass_through_cycle_is_error() {
740 let p = PolicyConfig {
741 name: "p".into(),
742 error_handler: None,
743 nodes: vec![
744 node("listener", "listener"),
745 supernode_instance("a", "identity"),
746 supernode_instance("b", "identity"),
747 node("client", "client"),
748 ],
749 edges: vec![
750 edge("listener.out", "a.in"),
751 edge("a.success", "b.in"),
752 edge("b.success", "a.in"), edge("a.error", "client.in"),
754 ],
755 };
756 let err = expand_policy(&p, &[identity_supernode()]).unwrap_err();
757 assert!(err.contains("pass-through cycle"), "got: {err}");
758 assert!(err.contains("a") && err.contains("b"), "got: {err}");
759 }
760
761 #[test]
764 fn test_named_output_boundary_port_name_is_its_id() {
765 let def = SupernodeConfig {
766 name: "custom-boundary".into(),
767 description: None,
768 nodes: vec![
769 node("input", "input"),
770 node("out1", "output"), node("err1", "error"),
772 node("process", "key-auth"),
773 ],
774 edges: vec![
775 edge("input.out", "process.in"),
776 edge("process.success", "out1.in"),
777 ],
778 };
779 let p = PolicyConfig {
780 name: "p".into(),
781 error_handler: None,
782 nodes: vec![
783 node("listener", "listener"),
784 supernode_instance("sb", "custom-boundary"),
785 node("client", "client"),
786 ],
787 edges: vec![edge("listener.out", "sb.in"), edge("sb.out1", "client.in")],
788 };
789 let out = expand_policy(&p, &[def]).unwrap();
790 assert!(
792 out.edges
793 .iter()
794 .any(|e| e.from == "sb/process.success" && e.to == "client.in"),
795 "edges: {:?}",
796 out.edges
797 .iter()
798 .map(|e| format!("{}->{}", e.from, e.to))
799 .collect::<Vec<_>>()
800 );
801 }
802
803 fn outcome_port_supernode() -> SupernodeConfig {
808 SupernodeConfig {
809 name: "outcome-def".into(),
810 description: None,
811 nodes: vec![
812 node("input", "input"),
813 node("output", "output"),
814 node("error", "error"),
815 node("auth", "key-auth"),
816 ],
817 edges: vec![
818 edge("input.out", "auth.in"),
819 edge("auth.denied", "output.in"),
820 ],
821 }
822 }
823
824 #[test]
827 fn test_inner_outcome_port_is_prefixed_and_preserved() {
828 let p = PolicyConfig {
829 name: "p".into(),
830 error_handler: None,
831 nodes: vec![
832 node("listener", "listener"),
833 supernode_instance("sec", "outcome-def"),
834 node("eh", "error-handler"),
835 node("client", "client"),
836 ],
837 edges: vec![
838 edge("listener.out", "sec.in"),
839 edge("sec.success", "client.in"),
840 edge("sec.error", "eh.in"),
841 ],
842 };
843 let out = expand_policy(&p, &[outcome_port_supernode()]).unwrap();
844 assert_eq!(
845 edge_set(&out),
846 vec![
847 "listener.out->sec/auth.in",
848 "sec/auth.denied->client.in", "sec/auth.error->eh.in", ]
851 );
852 }
853
854 #[test]
859 fn test_inner_outcome_port_to_output_boundary() {
860 let p = PolicyConfig {
861 name: "p".into(),
862 error_handler: None,
863 nodes: vec![
864 node("listener", "listener"),
865 supernode_instance("sec", "outcome-def"),
866 node("eh", "error-handler"),
867 node("client", "client"),
868 ],
869 edges: vec![
870 edge("listener.out", "sec.in"),
871 edge("sec.success", "client.in"),
872 edge("sec.error", "eh.in"),
873 ],
874 };
875 let out = expand_policy(&p, &[outcome_port_supernode()]).unwrap();
876 assert!(
877 out.edges
878 .iter()
879 .any(|e| e.from == "sec/auth.denied" && e.to == "client.in"),
880 "edges: {:?}",
881 out.edges
882 .iter()
883 .map(|e| format!("{}->{}", e.from, e.to))
884 .collect::<Vec<_>>()
885 );
886 assert!(
887 !out.edges
888 .iter()
889 .any(|e| e.from == "sec/auth.denied" && e.to == "eh.in"),
890 "custom outcome port must not be misrouted to the error target"
891 );
892 }
893
894 #[test]
904 fn test_outer_custom_port_on_instance_is_rejected() {
905 let p = PolicyConfig {
906 name: "p".into(),
907 error_handler: None,
908 nodes: vec![
909 node("listener", "listener"),
910 supernode_instance("sec", "secured-call"),
911 node("client", "client"),
912 ],
913 edges: vec![
914 edge("listener.out", "sec.in"),
915 edge("sec.denied", "client.in"), ],
917 };
918 let err = expand_policy(&p, &[secured_call()]).unwrap_err();
919 assert!(
920 err.contains("unknown port 'denied'")
921 && err.contains("supernode instance 'sec'")
922 && err.contains("success"),
923 "got: {err}"
924 );
925 }
926
927 #[test]
930 fn test_outer_out_alias_on_instance_is_accepted() {
931 let p = PolicyConfig {
932 name: "p".into(),
933 error_handler: None,
934 nodes: vec![
935 node("listener", "listener"),
936 supernode_instance("sec", "secured-call"),
937 node("client", "client"),
938 ],
939 edges: vec![edge("listener.out", "sec.in"), edge("sec.out", "client.in")],
940 };
941 let out = expand_policy(&p, &[secured_call()]).unwrap();
942 assert!(
943 out.edges
944 .iter()
945 .any(|e| e.from == "sec/up.success" && e.to == "client.in"),
946 "edges: {:?}",
947 out.edges
948 .iter()
949 .map(|e| format!("{}->{}", e.from, e.to))
950 .collect::<Vec<_>>()
951 );
952 }
953
954 #[test]
957 fn test_duplicate_success_outer_edge_on_instance_is_rejected() {
958 let p = PolicyConfig {
959 name: "p".into(),
960 error_handler: None,
961 nodes: vec![
962 node("listener", "listener"),
963 supernode_instance("sec", "secured-call"),
964 node("eh", "error-handler"),
965 node("client", "client"),
966 ],
967 edges: vec![
968 edge("listener.out", "sec.in"),
969 edge("sec.success", "client.in"),
970 edge("sec.out", "eh.in"), ],
972 };
973 let err = expand_policy(&p, &[secured_call()]).unwrap_err();
974 assert!(
975 err.contains("duplicate edge") && err.contains("'sec'"),
976 "got: {err}"
977 );
978 }
979
980 #[test]
982 fn test_duplicate_error_outer_edge_on_instance_is_rejected() {
983 let p = PolicyConfig {
984 name: "p".into(),
985 error_handler: None,
986 nodes: vec![
987 node("listener", "listener"),
988 supernode_instance("sec", "secured-call"),
989 node("eh", "error-handler"),
990 node("client", "client"),
991 ],
992 edges: vec![
993 edge("listener.out", "sec.in"),
994 edge("sec.success", "client.in"),
995 edge("sec.error", "eh.in"),
996 edge("sec.error", "client.in"),
997 ],
998 };
999 let err = expand_policy(&p, &[secured_call()]).unwrap_err();
1000 assert!(
1001 err.contains("duplicate edge") && err.contains("error"),
1002 "got: {err}"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_nested_supernode_is_error() {
1008 let nested_def = SupernodeConfig {
1009 name: "nested".into(),
1010 description: None,
1011 nodes: vec![
1012 node("input", "input"),
1013 node("output", "output"),
1014 node("error", "error"),
1015 supernode_instance("inner", "identity"), ],
1017 edges: vec![
1018 edge("input.out", "inner.in"),
1019 edge("inner.success", "output.in"),
1020 ],
1021 };
1022 let p = PolicyConfig {
1023 name: "p".into(),
1024 error_handler: None,
1025 nodes: vec![
1026 node("listener", "listener"),
1027 supernode_instance("n", "nested"),
1028 node("client", "client"),
1029 ],
1030 edges: vec![edge("listener.out", "n.in"), edge("n.success", "client.in")],
1031 };
1032 let err = expand_policy(&p, &[nested_def, identity_supernode()]).unwrap_err();
1033 assert!(
1034 err.contains("nested") && err.contains("nesting not supported"),
1035 "got: {err}"
1036 );
1037 }
1038
1039 fn error_pass_through() -> SupernodeConfig {
1041 SupernodeConfig {
1042 name: "error-passthrough".into(),
1043 description: None,
1044 nodes: vec![
1045 node("input", "input"),
1046 node("output", "output"),
1047 node("error", "error"),
1048 ],
1049 edges: vec![edge("input.out", "error.in")],
1050 }
1051 }
1052
1053 #[test]
1054 fn test_error_boundary_pass_through_with_wired_outer_error() {
1055 let p = PolicyConfig {
1056 name: "p".into(),
1057 error_handler: None,
1058 nodes: vec![
1059 node("listener", "listener"),
1060 supernode_instance("err_pass", "error-passthrough"),
1061 node("eh", "error-handler"),
1062 node("client", "client"),
1063 ],
1064 edges: vec![
1065 edge("listener.out", "err_pass.in"),
1066 edge("err_pass.error", "eh.in"),
1067 edge("err_pass.success", "client.in"),
1068 ],
1069 };
1070 let out = expand_policy(&p, &[error_pass_through()]).unwrap();
1071 assert!(!out.nodes.iter().any(|n| n.id.contains("err_pass")));
1073 assert!(
1075 out.edges
1076 .iter()
1077 .any(|e| e.from == "listener.out" && e.to == "eh.in"),
1078 "edges: {:?}",
1079 out.edges
1080 .iter()
1081 .map(|e| format!("{}->{}", e.from, e.to))
1082 .collect::<Vec<_>>()
1083 );
1084 }
1085
1086 #[test]
1087 fn test_error_boundary_pass_through_with_unwired_outer_error() {
1088 let p = PolicyConfig {
1089 name: "p".into(),
1090 error_handler: None,
1091 nodes: vec![
1092 node("listener", "listener"),
1093 supernode_instance("err_pass", "error-passthrough"),
1094 node("client", "client"),
1095 ],
1096 edges: vec![
1097 edge("listener.out", "err_pass.in"),
1098 edge("err_pass.success", "client.in"), ],
1100 };
1101 let out = expand_policy(&p, &[error_pass_through()]).unwrap();
1102 assert!(!out.edges.iter().any(|e| e.from == "listener.out"));
1104 }
1105
1106 fn mismatched_input_id() -> SupernodeConfig {
1108 SupernodeConfig {
1109 name: "custom-input".into(),
1110 description: None,
1111 nodes: vec![
1112 node("in1", "input"), node("output", "output"),
1114 node("error", "error"),
1115 node("process", "upstream"),
1116 ],
1117 edges: vec![
1118 edge("in1.out", "process.in"),
1119 edge("process.success", "output.in"),
1120 ],
1121 }
1122 }
1123
1124 #[test]
1125 fn test_mismatched_id_input_boundary_expands_correctly() {
1126 let p = PolicyConfig {
1127 name: "p".into(),
1128 error_handler: None,
1129 nodes: vec![
1130 node("listener", "listener"),
1131 supernode_instance("custom", "custom-input"),
1132 node("client", "client"),
1133 ],
1134 edges: vec![
1135 edge("listener.out", "custom.in"),
1136 edge("custom.success", "client.in"),
1137 ],
1138 };
1139 let out = expand_policy(&p, &[mismatched_input_id()]).unwrap();
1140 let ids: HashSet<&str> = out.nodes.iter().map(|n| n.id.as_str()).collect();
1142 assert!(ids.contains("custom/process"), "process node not inlined");
1143 assert!(
1144 !ids.contains("custom/in1"),
1145 "input boundary should not be inlined"
1146 );
1147 assert!(out
1149 .edges
1150 .iter()
1151 .any(|e| e.from == "custom/process.success" && e.to == "client.in"));
1152 assert!(
1154 !out.edges
1155 .iter()
1156 .any(|e| e.from.contains("in1") || e.to.contains("in1")),
1157 "no edge should reference input boundary id 'in1'; edges: {:?}",
1158 out.edges
1159 .iter()
1160 .map(|e| format!("{}->{}", e.from, e.to))
1161 .collect::<Vec<_>>()
1162 );
1163 }
1164
1165 #[test]
1168 fn test_pass_through_chain_into_another_instance() {
1169 let p = PolicyConfig {
1171 name: "p".into(),
1172 error_handler: None,
1173 nodes: vec![
1174 node("listener", "listener"),
1175 supernode_instance("x", "error-passthrough"),
1176 supernode_instance("y", "secured-call"),
1177 node("eh", "error-handler"),
1178 ],
1179 edges: vec![
1180 edge("listener.out", "x.in"),
1181 edge("x.error", "y.in"), edge("y.success", "eh.in"),
1183 edge("x.success", "eh.in"),
1184 ],
1185 };
1186 let out = expand_policy(&p, &[error_pass_through(), secured_call()]).unwrap();
1187 assert!(
1189 out.edges
1190 .iter()
1191 .any(|e| e.from == "listener.out" && e.to == "y/auth.in"),
1192 "listener.out should splice to y/auth.in; edges: {:?}",
1193 out.edges
1194 .iter()
1195 .map(|e| format!("{}->{}", e.from, e.to))
1196 .collect::<Vec<_>>()
1197 );
1198 assert!(
1200 !out.edges.iter().any(|e| e.to == "y.in"),
1201 "no edge should target instance port y.in (dangling); edges: {:?}",
1202 out.edges
1203 .iter()
1204 .map(|e| format!("{}->{}", e.from, e.to))
1205 .collect::<Vec<_>>()
1206 );
1207 }
1208
1209 #[test]
1214 fn test_multi_hop_pass_through_chain_resolves_to_fixed_point() {
1215 let p = PolicyConfig {
1216 name: "p".into(),
1217 error_handler: None,
1218 nodes: vec![
1219 node("listener", "listener"),
1220 supernode_instance("x", "error-passthrough"),
1221 supernode_instance("y", "error-passthrough"),
1222 supernode_instance("z", "secured-call"),
1223 node("eh", "error-handler"),
1224 ],
1225 edges: vec![
1226 edge("listener.out", "x.in"),
1227 edge("x.error", "y.in"),
1228 edge("y.error", "z.in"),
1229 edge("z.success", "eh.in"),
1230 edge("x.success", "eh.in"),
1231 edge("y.success", "eh.in"),
1232 ],
1233 };
1234 let out = expand_policy(&p, &[error_pass_through(), secured_call()]).unwrap();
1235 let edge_strs: Vec<String> = out
1236 .edges
1237 .iter()
1238 .map(|e| format!("{}->{}", e.from, e.to))
1239 .collect();
1240 assert!(
1242 out.edges
1243 .iter()
1244 .any(|e| e.from == "listener.out" && e.to == "z/auth.in"),
1245 "listener.out should splice to z/auth.in; edges: {edge_strs:?}"
1246 );
1247 assert!(
1249 !out.edges.iter().any(|e| e.from == "x.in"
1250 || e.to == "x.in"
1251 || e.from == "y.in"
1252 || e.to == "y.in"
1253 || e.from == "z.in"
1254 || e.to == "z.in"),
1255 "no edge should reference a bare instance port; edges: {edge_strs:?}"
1256 );
1257 }
1258
1259 #[test]
1263 fn test_multi_hop_pass_through_cycle_is_error() {
1264 let p = PolicyConfig {
1265 name: "p".into(),
1266 error_handler: None,
1267 nodes: vec![
1268 node("listener", "listener"),
1269 supernode_instance("x", "error-passthrough"),
1270 supernode_instance("y", "error-passthrough"),
1271 node("client", "client"),
1272 ],
1273 edges: vec![
1274 edge("listener.out", "x.in"),
1275 edge("x.error", "y.in"),
1276 edge("y.error", "x.in"), edge("x.success", "client.in"),
1278 edge("y.success", "client.in"),
1279 ],
1280 };
1281 let err = expand_policy(&p, &[error_pass_through()]).unwrap_err();
1282 assert!(err.contains("cycle"), "got: {err}");
1283 }
1284
1285 fn named_ports_supernode() -> SupernodeConfig {
1287 SupernodeConfig {
1288 name: "gate".into(),
1289 description: None,
1290 nodes: vec![
1291 node("input", "input"),
1292 node("output", "output"),
1293 node("denied", "output"),
1294 node("error", "error"),
1295 node("auth", "key-auth"),
1296 ],
1297 edges: vec![
1298 edge("input.out", "auth.in"),
1299 edge("auth.success", "output.in"),
1300 edge("auth.denied", "denied.in"),
1301 ],
1302 }
1303 }
1304
1305 #[test]
1308 fn test_named_output_ports_splice_to_distinct_targets() {
1309 let p = PolicyConfig {
1310 name: "p".into(),
1311 error_handler: None,
1312 nodes: vec![
1313 node("listener", "listener"),
1314 supernode_instance("gate", "gate"),
1315 node("reject", "error-handler"),
1316 node("client", "client"),
1317 ],
1318 edges: vec![
1319 edge("listener.out", "gate.in"),
1320 edge("gate.success", "client.in"),
1321 edge("gate.denied", "reject.in"),
1322 edge("reject.success", "client.in"),
1323 ],
1324 };
1325 let out = expand_policy(&p, &[named_ports_supernode()]).unwrap();
1326 assert_eq!(
1327 edge_set(&out),
1328 vec![
1329 "gate/auth.denied->reject.in",
1330 "gate/auth.success->client.in",
1331 "listener.out->gate/auth.in",
1332 "reject.success->client.in",
1333 ]
1334 );
1335 }
1336
1337 #[test]
1339 fn test_unwired_named_port_is_rejected() {
1340 let p = PolicyConfig {
1341 name: "p".into(),
1342 error_handler: None,
1343 nodes: vec![
1344 node("listener", "listener"),
1345 supernode_instance("gate", "gate"),
1346 node("client", "client"),
1347 ],
1348 edges: vec![
1349 edge("listener.out", "gate.in"),
1350 edge("gate.success", "client.in"),
1351 ],
1353 };
1354 let err = expand_policy(&p, &[named_ports_supernode()]).unwrap_err();
1355 assert!(
1356 err.contains("output port 'denied' of supernode instance 'gate' must be wired")
1357 && err.contains("add an edge from 'gate.denied'"),
1358 "got: {err}"
1359 );
1360 }
1361
1362 #[test]
1364 fn test_unwired_success_port_is_rejected() {
1365 let p = PolicyConfig {
1366 name: "p".into(),
1367 error_handler: None,
1368 nodes: vec![
1369 node("listener", "listener"),
1370 supernode_instance("gate", "gate"),
1371 node("reject", "error-handler"),
1372 node("client", "client"),
1373 ],
1374 edges: vec![
1375 edge("listener.out", "gate.in"),
1376 edge("gate.denied", "reject.in"),
1377 edge("reject.success", "client.in"),
1378 ],
1379 };
1380 let err = expand_policy(&p, &[named_ports_supernode()]).unwrap_err();
1381 assert!(
1382 err.contains("output port 'success' of supernode instance 'gate' must be wired"),
1383 "got: {err}"
1384 );
1385 }
1386
1387 fn named_only_supernode() -> SupernodeConfig {
1389 SupernodeConfig {
1390 name: "named-only".into(),
1391 description: None,
1392 nodes: vec![
1393 node("input", "input"),
1394 node("done", "output"),
1395 node("error", "error"),
1396 node("up", "upstream"),
1397 ],
1398 edges: vec![edge("input.out", "up.in"), edge("up.success", "done.in")],
1399 }
1400 }
1401
1402 #[test]
1403 fn test_success_rejected_when_no_output_id_boundary() {
1404 let p = PolicyConfig {
1405 name: "p".into(),
1406 error_handler: None,
1407 nodes: vec![
1408 node("listener", "listener"),
1409 supernode_instance("n", "named-only"),
1410 node("client", "client"),
1411 ],
1412 edges: vec![edge("listener.out", "n.in"), edge("n.success", "client.in")],
1413 };
1414 let err = expand_policy(&p, &[named_only_supernode()]).unwrap_err();
1415 assert!(
1416 err.contains("unknown port 'success'") && err.contains("done"),
1417 "unknown-port error must list the exposed ports; got: {err}"
1418 );
1419 }
1420
1421 #[test]
1422 fn test_named_only_supernode_routes_via_named_port() {
1423 let p = PolicyConfig {
1424 name: "p".into(),
1425 error_handler: None,
1426 nodes: vec![
1427 node("listener", "listener"),
1428 supernode_instance("n", "named-only"),
1429 node("client", "client"),
1430 ],
1431 edges: vec![edge("listener.out", "n.in"), edge("n.done", "client.in")],
1432 };
1433 let out = expand_policy(&p, &[named_only_supernode()]).unwrap();
1434 assert!(out
1435 .edges
1436 .iter()
1437 .any(|e| e.from == "n/up.success" && e.to == "client.in"));
1438 }
1439
1440 #[test]
1443 fn test_pass_through_via_named_boundary() {
1444 let def = SupernodeConfig {
1445 name: "shortcut".into(),
1446 description: None,
1447 nodes: vec![
1448 node("input", "input"),
1449 node("denied", "output"),
1450 node("error", "error"),
1451 ],
1452 edges: vec![edge("input.out", "denied.in")],
1453 };
1454 let p = PolicyConfig {
1455 name: "p".into(),
1456 error_handler: None,
1457 nodes: vec![
1458 node("listener", "listener"),
1459 supernode_instance("s", "shortcut"),
1460 node("client", "client"),
1461 ],
1462 edges: vec![edge("listener.out", "s.in"), edge("s.denied", "client.in")],
1463 };
1464 let out = expand_policy(&p, &[def]).unwrap();
1465 assert!(out
1466 .edges
1467 .iter()
1468 .any(|e| e.from == "listener.out" && e.to == "client.in"));
1469 }
1470
1471 #[test]
1473 fn test_duplicate_named_port_outer_edge_is_rejected() {
1474 let p = PolicyConfig {
1475 name: "p".into(),
1476 error_handler: None,
1477 nodes: vec![
1478 node("listener", "listener"),
1479 supernode_instance("gate", "gate"),
1480 node("reject", "error-handler"),
1481 node("client", "client"),
1482 ],
1483 edges: vec![
1484 edge("listener.out", "gate.in"),
1485 edge("gate.success", "client.in"),
1486 edge("gate.denied", "reject.in"),
1487 edge("gate.denied", "client.in"),
1488 edge("reject.success", "client.in"),
1489 ],
1490 };
1491 let err = expand_policy(&p, &[named_ports_supernode()]).unwrap_err();
1492 assert!(
1493 err.contains("duplicate edge") && err.contains("'denied'"),
1494 "got: {err}"
1495 );
1496 }
1497
1498 fn multi_error_supernode() -> SupernodeConfig {
1501 SupernodeConfig {
1502 name: "gate".into(),
1503 description: None,
1504 nodes: vec![
1505 node("input", "input"),
1506 node("output", "output"),
1507 node("error", "error"),
1508 node("auth-error", "error"),
1509 node("auth", "key-auth"),
1510 node("up", "upstream"),
1511 ],
1512 edges: vec![
1513 edge("input.out", "auth.in"),
1514 edge("auth.success", "up.in"),
1515 edge("auth.denied", "up.in"),
1516 edge("auth.error", "auth-error.in"),
1517 edge("up.success", "output.in"),
1518 ],
1519 }
1520 }
1521
1522 #[test]
1525 fn test_named_error_port_and_default_black_box() {
1526 let p = PolicyConfig {
1527 name: "p".into(),
1528 error_handler: None,
1529 nodes: vec![
1530 node("listener", "listener"),
1531 supernode_instance("gate", "gate"),
1532 node("eh1", "error-handler"),
1533 node("eh2", "error-handler"),
1534 node("client", "client"),
1535 ],
1536 edges: vec![
1537 edge("listener.out", "gate.in"),
1538 edge("gate.success", "client.in"),
1539 edge("gate.error", "eh1.in"),
1540 edge("gate.auth-error", "eh2.in"),
1541 edge("eh1.success", "client.in"),
1542 edge("eh2.success", "client.in"),
1543 ],
1544 };
1545 let out = expand_policy(&p, &[multi_error_supernode()]).unwrap();
1546 assert!(out
1548 .edges
1549 .iter()
1550 .any(|e| e.from == "gate/auth.error" && e.to == "eh2.in"));
1551 assert!(out
1553 .edges
1554 .iter()
1555 .any(|e| e.from == "gate/up.error" && e.to == "eh1.in"));
1556 assert_eq!(
1558 out.edges
1559 .iter()
1560 .filter(|e| e.from == "gate/auth.error")
1561 .count(),
1562 1
1563 );
1564 }
1565
1566 fn renamed_error_supernode() -> SupernodeConfig {
1569 SupernodeConfig {
1570 name: "renamed".into(),
1571 description: None,
1572 nodes: vec![
1573 node("input", "input"),
1574 node("output", "output"),
1575 node("oops", "error"),
1576 node("up", "upstream"),
1577 ],
1578 edges: vec![edge("input.out", "up.in"), edge("up.success", "output.in")],
1579 }
1580 }
1581
1582 #[test]
1583 fn test_no_black_box_without_default_error_boundary() {
1584 let p = PolicyConfig {
1585 name: "p".into(),
1586 error_handler: None,
1587 nodes: vec![
1588 node("listener", "listener"),
1589 supernode_instance("r", "renamed"),
1590 node("eh", "error-handler"),
1591 node("client", "client"),
1592 ],
1593 edges: vec![
1594 edge("listener.out", "r.in"),
1595 edge("r.success", "client.in"),
1596 edge("r.oops", "eh.in"),
1597 edge("eh.success", "client.in"),
1598 ],
1599 };
1600 let out = expand_policy(&p, &[renamed_error_supernode()]).unwrap();
1601 assert!(!out.edges.iter().any(|e| e.from == "r/up.error"));
1603 }
1604
1605 #[test]
1608 fn test_error_port_unknown_when_default_renamed() {
1609 let p = PolicyConfig {
1610 name: "p".into(),
1611 error_handler: None,
1612 nodes: vec![
1613 node("listener", "listener"),
1614 supernode_instance("r", "renamed"),
1615 node("client", "client"),
1616 ],
1617 edges: vec![
1618 edge("listener.out", "r.in"),
1619 edge("r.success", "client.in"),
1620 edge("r.error", "client.in"),
1621 ],
1622 };
1623 let err = expand_policy(&p, &[renamed_error_supernode()]).unwrap_err();
1624 assert!(
1625 err.contains("unknown port 'error'") && err.contains("oops"),
1626 "got: {err}"
1627 );
1628 }
1629
1630 #[test]
1632 fn test_unwired_named_error_port_drops_exit_edges() {
1633 let mut def = renamed_error_supernode();
1634 def.nodes.push(node("auth", "key-auth"));
1635 def.edges = vec![
1636 edge("input.out", "auth.in"),
1637 edge("auth.success", "up.in"),
1638 edge("auth.denied", "up.in"),
1639 edge("auth.error", "oops.in"),
1640 edge("up.success", "output.in"),
1641 ];
1642 let p = PolicyConfig {
1643 name: "p".into(),
1644 error_handler: None,
1645 nodes: vec![
1646 node("listener", "listener"),
1647 supernode_instance("r", "renamed"),
1648 node("client", "client"),
1649 ],
1650 edges: vec![
1651 edge("listener.out", "r.in"),
1652 edge("r.success", "client.in"),
1653 ],
1655 };
1656 let out = expand_policy(&p, &[def]).unwrap();
1657 assert!(!out.edges.iter().any(|e| e.from == "r/auth.error"));
1658 assert!(out
1659 .edges
1660 .iter()
1661 .any(|e| e.from == "r/up.success" && e.to == "client.in"));
1662 }
1663}