@push.rocks/smartnftables 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -676,3 +676,417 @@ fn host_v2_published_ports_reject_unbound_targets_conflicts_and_out_of_range_por
676
676
  assert!(normalized(published_host(ports(13))).unwrap().prepare().is_err());
677
677
  println!("PUBLISHED_MAX ports=12 operations={} bytes={bytes}", program.len());
678
678
  }
679
+
680
+ pub(crate) fn router_published_port(protocol: &str, transit_port: u16, endpoint_port: u16) -> Value {
681
+ json!({"protocol":protocol,"transitPort":transit_port,"endpointPort":endpoint_port,
682
+ "endpointAddress":"10.241.0.2","transitSourceAddress":"10.240.0.2"})
683
+ }
684
+ pub(crate) fn published_router(ports: Value) -> Value {
685
+ let mut value = router();
686
+ value["scope"]["publishedPorts"] = ports;
687
+ value
688
+ }
689
+
690
+ #[test]
691
+ fn router_v2_without_published_ports_keeps_its_exact_digest_and_compiled_bytes() {
692
+ use sha2::{Digest, Sha256};
693
+ let baseline = prepared(router());
694
+ // Frozen before the optional publication field existed.
695
+ assert_eq!(
696
+ baseline.digest,
697
+ "sha256:762a556e0bf512b653acfcf86699ac7681f26e6cc9ea1d26671443e3d2ca0b61"
698
+ );
699
+ let bytes = program_bytes(&baseline, "snft_v2_router_golden");
700
+ assert_eq!(bytes.len(), 53272);
701
+ assert_eq!(
702
+ format!("{:x}", Sha256::digest(&bytes)),
703
+ "66850722fbd38a45a986cebeddcc75a541ebb1e2f003ea4375621decb2e08f99"
704
+ );
705
+ let serialized = serde_json::to_string(&baseline.policy).unwrap();
706
+ assert!(!serialized.contains("publishedPorts"));
707
+ // An explicit empty publication is the same canonical policy, digest and graph.
708
+ assert_eq!(prepared(published_router(json!([]))), baseline);
709
+ let (chains, rules) = graph(&baseline, "snft_v2_router_golden");
710
+ assert!(!chains.contains(&"pre".to_string()));
711
+ assert!(rules.iter().all(|(chain, _)| chain != "pre"));
712
+ }
713
+
714
+ #[test]
715
+ fn router_v2_published_ports_compile_handoff_dnat_classification_and_admission() {
716
+ use sha2::{Digest, Sha256};
717
+ let baseline = graph(&prepared(router()), "snft_v2_router_golden");
718
+ let value = published_router(json!([
719
+ router_published_port("tcp", 443, 8443),
720
+ router_published_port("udp", 8125, 8125)
721
+ ]));
722
+ let published = prepared(value.clone());
723
+ assert_ne!(published.digest, prepared(router()).digest);
724
+ // Publication order is canonical, so a reordered list is the same policy.
725
+ let mut reordered = value;
726
+ reordered["scope"]["publishedPorts"]
727
+ .as_array_mut()
728
+ .unwrap()
729
+ .reverse();
730
+ assert_eq!(prepared(reordered), published);
731
+ let (chains, rules) = graph(&published, "snft_v2_router_golden");
732
+ assert_eq!(chains.len(), baseline.0.len() + 1);
733
+ assert_eq!(chains.iter().filter(|name| *name == "pre").count(), 1);
734
+ // Six rules per publication: one translation, one handoff ingress and one
735
+ // endpoint ingress classification, and the three forward admissions.
736
+ assert_eq!(rules.len(), baseline.1.len() + 12);
737
+ let translations: Vec<_> = rules.iter().filter(|(chain, _)| chain == "pre").collect();
738
+ assert_eq!(translations.len(), 2);
739
+ for (_, expressions) in &translations {
740
+ assert_eq!(expressions.last().unwrap(), "nat");
741
+ assert_eq!(
742
+ expressions.iter().filter(|name| *name == "immediate").count(),
743
+ 2
744
+ );
745
+ }
746
+ let counted = |graph: &Vec<(String, Vec<String>)>, chain: &str| {
747
+ graph.iter().filter(|(name, _)| name == chain).count()
748
+ };
749
+ assert_eq!(
750
+ counted(&rules, "forward"),
751
+ counted(&baseline.1, "forward") + 6
752
+ );
753
+ // The classification leads the handoff ingress barrier, ahead of every denial
754
+ // in it, and never touches conntrack because raw runs before it.
755
+ let returns: Vec<_> = rules
756
+ .iter()
757
+ .filter(|(chain, _)| chain == "raw_return")
758
+ .map(|(_, expressions)| expressions)
759
+ .collect();
760
+ assert_eq!(returns.len(), counted(&baseline.1, "raw_return") + 2);
761
+ for expressions in &returns[..2] {
762
+ assert!(!expressions.contains(&"ct".to_string()));
763
+ }
764
+ assert!(returns[2..]
765
+ .iter()
766
+ .any(|expressions| expressions.contains(&"ct".to_string())));
767
+ assert_eq!(returns.last().unwrap().len(), 1);
768
+ // The endpoint side is classified in the workload ingress chain, behind the
769
+ // handoff barrier that opens it and ahead of every leased zone assignment.
770
+ let incoming: Vec<_> = rules
771
+ .iter()
772
+ .filter(|(chain, _)| chain == "raw_in")
773
+ .map(|(_, expressions)| expressions)
774
+ .collect();
775
+ assert_eq!(incoming.len(), counted(&baseline.1, "raw_in") + 2);
776
+ for expressions in &incoming[3..5] {
777
+ assert!(!expressions.contains(&"ct".to_string()));
778
+ }
779
+ assert!(incoming[5..]
780
+ .iter()
781
+ .any(|expressions| expressions.contains(&"ct".to_string())));
782
+ let bytes = program_bytes(&published, "snft_v2_router_golden");
783
+ assert_eq!(bytes.len(), 67818);
784
+ assert_eq!(
785
+ format!("{:x}", Sha256::digest(&bytes)),
786
+ "62bf0f5aace2a19fb8a92571d1c4d45d774aa003884c239e473c67a7e103bb12"
787
+ );
788
+ }
789
+
790
+ #[test]
791
+ fn router_v2_published_ports_are_owned_by_their_generation_and_leave_with_it() {
792
+ let published = prepared(published_router(json!([router_published_port(
793
+ "tcp", 443, 8443
794
+ )])));
795
+ let rollback = prepared(router());
796
+ let (chains, rules) = graph(&published, "snft_v2_router_rollback");
797
+ assert!(chains.contains(&"pre".to_string()));
798
+ assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
799
+ // Every operation names the owned table, and the rollback target compiles the
800
+ // same graph as before publication; reconcile deletes the complete previous
801
+ // graph and re-adds the target in one batch, so the publication cannot survive.
802
+ let (rollback_chains, rollback_rules) = graph(&rollback, "snft_v2_router_rollback");
803
+ assert!(!rollback_chains.contains(&"pre".to_string()));
804
+ assert!(rollback_rules.iter().all(|(chain, _)| chain != "pre"));
805
+ assert_eq!(rollback_chains.len() + 1, chains.len());
806
+ assert_eq!(rollback_rules.len() + 6, rules.len());
807
+ assert_ne!(published.digest, rollback.digest);
808
+ // A released generation takes its publication with it: the scope without
809
+ // generations rejects the publication it could no longer bind.
810
+ let mut revoked = published_router(json!([router_published_port("tcp", 443, 8443)]));
811
+ revoked["scope"]["generations"] = json!([]);
812
+ assert!(normalized(revoked).is_err());
813
+ }
814
+
815
+ #[test]
816
+ fn router_v2_published_ports_reject_unbound_endpoints_conflicts_and_out_of_range_ports() {
817
+ assert!(normalized(published_router(json!([router_published_port("tcp", 443, 8443)]))).is_ok());
818
+ let mut unknown = router_published_port("tcp", 443, 8443);
819
+ unknown["endpointId"] = json!("work");
820
+ for invalid in [
821
+ published_router(json!([unknown])),
822
+ published_router(json!([router_published_port("sctp", 443, 8443)])),
823
+ published_router(json!([router_published_port("tcp", 0, 8443)])),
824
+ published_router(json!([router_published_port("tcp", 443, 0)])),
825
+ published_router(json!([changed(
826
+ router_published_port("tcp", 443, 8443),
827
+ "/transitPort",
828
+ json!(65536)
829
+ )])),
830
+ published_router(json!([changed(
831
+ router_published_port("tcp", 443, 8443),
832
+ "/endpointPort",
833
+ json!(65536)
834
+ )])),
835
+ published_router(json!([changed(
836
+ router_published_port("tcp", 443, 8443),
837
+ "/transitPort",
838
+ json!(-1)
839
+ )])),
840
+ // The destination must be a current workload endpoint address of this scope.
841
+ published_router(json!([changed(
842
+ router_published_port("tcp", 443, 8443),
843
+ "/endpointAddress",
844
+ json!("10.241.0.3")
845
+ )])),
846
+ // Not the endpoint's router-local gateway, and not the transit address.
847
+ published_router(json!([changed(
848
+ router_published_port("tcp", 443, 8443),
849
+ "/endpointAddress",
850
+ json!("10.241.0.1")
851
+ )])),
852
+ published_router(json!([changed(
853
+ router_published_port("tcp", 443, 8443),
854
+ "/endpointAddress",
855
+ json!("10.240.0.2")
856
+ )])),
857
+ // Not loopback, not a broadcast/multicast address, and IPv4 only.
858
+ published_router(json!([changed(
859
+ router_published_port("tcp", 443, 8443),
860
+ "/endpointAddress",
861
+ json!("127.0.0.1")
862
+ )])),
863
+ published_router(json!([changed(
864
+ router_published_port("tcp", 443, 8443),
865
+ "/endpointAddress",
866
+ json!("fd00::2")
867
+ )])),
868
+ // A TUN endpoint keeps private routing only; publication needs a veth.
869
+ published_router(json!([changed(
870
+ router_published_port("tcp", 443, 8443),
871
+ "/endpointAddress",
872
+ json!("10.242.0.2")
873
+ )])),
874
+ // The published address must be a current leased transit source address
875
+ // that is present on the handoff.
876
+ published_router(json!([changed(
877
+ router_published_port("tcp", 443, 8443),
878
+ "/transitSourceAddress",
879
+ json!("10.240.0.3")
880
+ )])),
881
+ published_router(json!([changed(
882
+ router_published_port("tcp", 443, 8443),
883
+ "/transitSourceAddress",
884
+ json!("10.241.0.1")
885
+ )])),
886
+ // A published port cannot collide with the leased outbound SNAT ranges
887
+ // translated to that same transit address.
888
+ published_router(json!([router_published_port("tcp", 10500, 8443)])),
889
+ published_router(json!([router_published_port("udp", 10000, 8443)])),
890
+ // One protocol/port is published exactly once across the scope.
891
+ published_router(json!([
892
+ router_published_port("tcp", 443, 8443),
893
+ router_published_port("tcp", 443, 9443)
894
+ ])),
895
+ published_router(json!((0..65)
896
+ .map(|index| router_published_port("tcp", 20000 + index, 8443))
897
+ .collect::<Vec<_>>())),
898
+ ] {
899
+ assert!(normalized(invalid).is_err());
900
+ }
901
+ // Bounded input still meets the atomic reserve first: every publication costs
902
+ // six operations, so prepare rejects before any kernel work.
903
+ let ports = |count: u16| -> Value {
904
+ json!((0..count)
905
+ .map(|index| router_published_port("tcp", 20000 + index, 8443))
906
+ .collect::<Vec<_>>())
907
+ };
908
+ let prepared = prepared(published_router(ports(5)));
909
+ let program = prepared
910
+ .program(&format!("snft_{}", "x".repeat(59)))
911
+ .unwrap();
912
+ let bytes: usize = program
913
+ .iter()
914
+ .map(|(_, attributes)| crate::wire::encode_attrs(attributes).len() + 20)
915
+ .sum();
916
+ assert!(program.len() <= 768 && bytes <= 100_000);
917
+ assert!(normalized(published_router(ports(6)))
918
+ .unwrap()
919
+ .prepare()
920
+ .is_err());
921
+ println!("ROUTER_PUBLISHED_MAX ports=5 operations={} bytes={bytes}", program.len());
922
+ }
923
+
924
+ /// One arriving IPv4 tuple for the ordered raw classifiers of a compiled chain.
925
+ #[derive(Clone, Copy)]
926
+ struct Arrival {
927
+ interface_index: u32,
928
+ interface_name: &'static str,
929
+ protocol: u8,
930
+ source: [u8; 4],
931
+ destination: [u8; 4],
932
+ source_port: u16,
933
+ destination_port: u16,
934
+ }
935
+ /// Walks one compiled chain in program order and reports the first rule that
936
+ /// matches an arriving tuple as (position, verdict, conntrack zone it sets). Raw
937
+ /// prerouting classification is the only hook this covers, so an unexpected
938
+ /// expression panics instead of being silently ignored.
939
+ fn classify(prepared: &Prepared, chain: &str, arrival: &Arrival) -> Option<(usize, i32, u16)> {
940
+ let table = "snft_v2_router_order";
941
+ let mut position = 0;
942
+ for (kind, attributes) in prepared.program(table).unwrap() {
943
+ if kind == 3 || crate::wire::text(&attributes, 2).unwrap() != chain {
944
+ continue;
945
+ }
946
+ let index = position;
947
+ position += 1;
948
+ let mut pending: Option<Vec<u8>> = None;
949
+ let mut immediate: Option<Vec<u8>> = None;
950
+ let mut verdict = 0;
951
+ let mut zone = 0;
952
+ let mut matched = true;
953
+ for expression in
954
+ crate::wire::attrs(&crate::wire::one(&attributes, 4).unwrap().value).unwrap()
955
+ {
956
+ let fields = crate::wire::attrs(&expression.value).unwrap();
957
+ let data = crate::wire::attrs(&crate::wire::one(&fields, 2).unwrap().value).unwrap();
958
+ match crate::wire::text(&fields, 1).unwrap().as_str() {
959
+ "meta" => {
960
+ pending = Some(match crate::wire::number(&data, 2).unwrap() {
961
+ 4 => arrival.interface_index.to_ne_bytes().to_vec(),
962
+ 6 => [arrival.interface_name.as_bytes(), &[0]].concat(),
963
+ 15 => vec![libc::NFPROTO_IPV4 as u8],
964
+ 16 => vec![arrival.protocol],
965
+ key => panic!("meta {key}"),
966
+ })
967
+ }
968
+ "payload" => {
969
+ pending = Some(
970
+ match (
971
+ crate::wire::number(&data, 2).unwrap(),
972
+ crate::wire::number(&data, 3).unwrap(),
973
+ ) {
974
+ (1, 12) => arrival.source.to_vec(),
975
+ (1, 16) => arrival.destination.to_vec(),
976
+ (2, 0) => arrival.source_port.to_be_bytes().to_vec(),
977
+ (2, 2) => arrival.destination_port.to_be_bytes().to_vec(),
978
+ load => panic!("payload {load:?}"),
979
+ },
980
+ )
981
+ }
982
+ "bitwise" => {
983
+ let mask = crate::wire::attrs(&crate::wire::one(&data, 4).unwrap().value)
984
+ .unwrap()[0]
985
+ .value
986
+ .clone();
987
+ let loaded = pending.take().unwrap();
988
+ pending = Some(
989
+ loaded
990
+ .iter()
991
+ .zip(&mask)
992
+ .map(|(byte, mask)| byte & mask)
993
+ .collect(),
994
+ );
995
+ }
996
+ "cmp" => {
997
+ assert_eq!(crate::wire::number(&data, 2).unwrap(), 0);
998
+ let expected = crate::wire::attrs(&crate::wire::one(&data, 3).unwrap().value)
999
+ .unwrap()[0]
1000
+ .value
1001
+ .clone();
1002
+ matched = pending.take().unwrap() == expected;
1003
+ }
1004
+ "ct" => {
1005
+ // Only the zone store reaches raw classification; a loaded
1006
+ // conntrack key would need a conntrack lookup that has not run.
1007
+ assert_eq!(crate::wire::number(&data, 2).unwrap(), 17);
1008
+ assert_eq!(crate::wire::number(&data, 4).unwrap(), 1);
1009
+ zone = u16::from_ne_bytes(immediate.take().unwrap().try_into().unwrap());
1010
+ }
1011
+ "immediate" => {
1012
+ let inner =
1013
+ crate::wire::attrs(&crate::wire::one(&data, 2).unwrap().value).unwrap();
1014
+ if crate::wire::number(&data, 1).unwrap() == 0 {
1015
+ let code = crate::wire::attrs(&crate::wire::one(&inner, 2).unwrap().value)
1016
+ .unwrap();
1017
+ verdict = crate::wire::number(&code, 1).unwrap() as i32;
1018
+ } else {
1019
+ immediate = Some(crate::wire::one(&inner, 1).unwrap().value.clone());
1020
+ }
1021
+ }
1022
+ other => panic!("{other}"),
1023
+ }
1024
+ if !matched {
1025
+ break;
1026
+ }
1027
+ }
1028
+ if matched {
1029
+ return Some((index, verdict, zone));
1030
+ }
1031
+ }
1032
+ None
1033
+ }
1034
+
1035
+ #[test]
1036
+ fn router_v2_published_endpoint_traffic_is_classified_before_the_leased_grants() {
1037
+ // A published reply to a client whose source port equals the leased grant's
1038
+ // destination port on its public prefix. Raw runs before conntrack, so only
1039
+ // the payload tuple separates it from ordinary leased egress.
1040
+ let reply = Arrival {
1041
+ interface_index: 2,
1042
+ interface_name: "work",
1043
+ protocol: 6,
1044
+ source: [10, 241, 0, 2],
1045
+ destination: [198, 51, 100, 7],
1046
+ source_port: 9443,
1047
+ destination_port: 443,
1048
+ };
1049
+ // Without the publication that exact tuple is leased into the generation zone,
1050
+ // so the ordering below is what keeps the publication usable at all.
1051
+ let leased = classify(&prepared(router()), "raw_in", &reply).unwrap();
1052
+ assert_eq!((leased.1, leased.2), (1, 1));
1053
+ let published = prepared(published_router(json!([router_published_port(
1054
+ "tcp", 443, 9443
1055
+ )])));
1056
+ let (index, verdict, zone) = classify(&published, "raw_in", &reply).unwrap();
1057
+ // Accepted with no zone set: the reply stays in the default zone, where the
1058
+ // translated publication and its admission live.
1059
+ assert_eq!((verdict, zone), (1, 0));
1060
+ // Every other workload flow keeps its leased classification, and the leased
1061
+ // classifier that claims the tuple above is reached only after this one.
1062
+ let (leased_index, leased_verdict, leased_zone) = classify(
1063
+ &published,
1064
+ "raw_in",
1065
+ &Arrival {
1066
+ source_port: 50000,
1067
+ ..reply
1068
+ },
1069
+ )
1070
+ .unwrap();
1071
+ assert_eq!((leased_verdict, leased_zone), (1, 1));
1072
+ assert!(index < leased_index, "{index} {leased_index}");
1073
+ // The classification is exact: another link, another workload address and
1074
+ // another protocol are not published and are not classified here.
1075
+ for foreign in [
1076
+ Arrival {
1077
+ interface_index: 3,
1078
+ interface_name: "vpn",
1079
+ ..reply
1080
+ },
1081
+ Arrival {
1082
+ source: [10, 241, 0, 3],
1083
+ ..reply
1084
+ },
1085
+ Arrival {
1086
+ protocol: 17,
1087
+ ..reply
1088
+ },
1089
+ ] {
1090
+ assert!(classify(&published, "raw_in", &foreign).is_none());
1091
+ }
1092
+ }
@@ -25,7 +25,7 @@ pub(super) fn connect(a: &str, aname: &str, aip: &str, b: &str, bname: &str, bip
25
25
  ns_ip(ns, &["link", "set", name, "up"]);
26
26
  }
27
27
  }
28
- fn binding(name: &str, address: &str, kind: &str) -> Value {
28
+ pub(super) fn binding(name: &str, address: &str, kind: &str) -> Value {
29
29
  let output = std::process::Command::new("/bin/ip")
30
30
  .args(["-j", "-d", "link", "show", "dev", name])
31
31
  .output()
@@ -37,12 +37,12 @@ fn binding(name: &str, address: &str, kind: &str) -> Value {
37
37
  "interfaceLinkIndex":if kind=="tun" {observed["ifindex"].clone()} else {observed["link_index"].clone()},
38
38
  "requiredIpv4Addresses":[address]})
39
39
  }
40
- fn protection() -> Value {
40
+ pub(super) fn protection() -> Value {
41
41
  json!({"authorityDigest":format!("sha256:{}","b".repeat(64)),"prefixes":["10.0.0.0/8","192.0.2.0/24"],
42
42
  "platformEndpoints":[{"id":"dns","address":"192.0.2.3","protocol":"udp","port":5300},
43
43
  {"id":"mail","address":"192.0.2.3","protocol":"tcp","port":3000}]})
44
44
  }
45
- fn allocation() -> Value {
45
+ pub(super) fn allocation() -> Value {
46
46
  json!({"lease":{"id":"lease-a","generation":1,"digest":format!("sha256:{}","a".repeat(64))},
47
47
  "transitSourceAddress":"10.240.0.2","sourcePortRanges":[{"protocol":"udp","first":10000,"last":10015},
48
48
  {"protocol":"tcp","first":10016,"last":10031}]})
@@ -113,7 +113,7 @@ fn hostile_capture_fixture() {
113
113
  // This is a persistent *foreign fixture* table. Drop its process socket;
114
114
  // native managed-table ownership never adopts or removes this graph.
115
115
  }
116
- fn control(source: &UdpSocket, destination: &str, target: &UdpSocket, allowed: bool) {
116
+ pub(super) fn control(source: &UdpSocket, destination: &str, target: &UdpSocket, allowed: bool) {
117
117
  assert_eq!(source.send_to(b"probe", destination).unwrap(), 5);
118
118
  if allowed {
119
119
  assert_eq!(target.recv(&mut [0; 64]).unwrap(), 5);
@@ -305,7 +305,7 @@ const PUBLISH_SOURCE: &str = "hp_source";
305
305
  const PUBLISH_HOST: &str = "hp_host";
306
306
  const PUBLISH_PEER: &str = "hp_peer";
307
307
 
308
- fn chain_names(owner: &mut Owner) -> Vec<String> {
308
+ pub(super) fn chain_names(owner: &mut Owner) -> Vec<String> {
309
309
  owner
310
310
  .graph()
311
311
  .unwrap()
@@ -317,7 +317,7 @@ fn chain_names(owner: &mut Owner) -> Vec<String> {
317
317
  }
318
318
  /// Inbound publication through the uplink: the workload must observe the actual
319
319
  /// client address, and the client must observe the published address back.
320
- fn published_udp(client: &UdpSocket, workload: &UdpSocket, published: &str, client_address: &str) {
320
+ pub(super) fn published_udp(client: &UdpSocket, workload: &UdpSocket, published: &str, client_address: &str) {
321
321
  let mut bytes = [0; 64];
322
322
  assert_eq!(client.send_to(b"query", published).unwrap(), 5);
323
323
  let (size, observed) = workload.recv_from(&mut bytes).expect("published delivery");
@@ -328,7 +328,7 @@ fn published_udp(client: &UdpSocket, workload: &UdpSocket, published: &str, clie
328
328
  assert_eq!(&bytes[..size], b"reply");
329
329
  assert_eq!(from.to_string(), published);
330
330
  }
331
- fn dark_udp(client: &UdpSocket, workload: &UdpSocket, published: &str) {
331
+ pub(super) fn dark_udp(client: &UdpSocket, workload: &UdpSocket, published: &str) {
332
332
  assert_eq!(client.send_to(b"query", published).unwrap(), 5);
333
333
  assert!(workload.recv(&mut [0; 64]).is_err());
334
334
  }
@@ -204,3 +204,48 @@ fn published_host_graph_identity_preserves_destination_nat_and_admission_operand
204
204
  changed.iter().filter(|(chain, _)| chain == "forward").count()
205
205
  );
206
206
  }
207
+
208
+ /// A published router generation must survive the same apply-time graph identity
209
+ /// as the rest of the table: the kernel dump is compared operand by operand, so
210
+ /// the workload translation and its admissions cannot be silently rewritten.
211
+ #[test]
212
+ fn published_router_graph_identity_preserves_destination_nat_and_admission_operands() {
213
+ let compiled = |endpoint_port: u16| {
214
+ let value = crate::egress::tests::published_router(serde_json::json!([
215
+ crate::egress::tests::router_published_port("tcp", 443, endpoint_port)
216
+ ]));
217
+ let prepared = serde_json::from_value::<crate::egress::Policy>(value)
218
+ .unwrap()
219
+ .prepare()
220
+ .unwrap();
221
+ let mut chains = Vec::new();
222
+ let mut rules = Vec::new();
223
+ for (kind, attributes) in prepared.program("snft_identity").unwrap() {
224
+ if kind == 3 {
225
+ chains.push(super::chain_identity(&attributes).unwrap());
226
+ } else {
227
+ rules.push(rule_identity(&attributes).unwrap());
228
+ }
229
+ }
230
+ (chains, rules)
231
+ };
232
+ let (chains, rules) = compiled(8443);
233
+ assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
234
+ assert_eq!(chains.len(), 16);
235
+ let translation = rules.iter().find(|(chain, _)| chain == "pre").unwrap();
236
+ let (_, changed) = compiled(9443);
237
+ let other = changed.iter().find(|(chain, _)| chain == "pre").unwrap();
238
+ assert_ne!(translation.1, other.1);
239
+ assert_eq!(
240
+ rules.iter().filter(|(chain, _)| chain == "forward").count(),
241
+ changed.iter().filter(|(chain, _)| chain == "forward").count()
242
+ );
243
+ // The handoff ingress classification is part of the same identity.
244
+ assert_eq!(
245
+ rules.iter().filter(|(chain, _)| chain == "raw_return").count(),
246
+ changed
247
+ .iter()
248
+ .filter(|(chain, _)| chain == "raw_return")
249
+ .count()
250
+ );
251
+ }