@push.rocks/smartnftables 1.4.0 → 1.6.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.
Files changed (53) hide show
  1. package/changelog.md +26 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.d.ts +17 -0
  8. package/dist_ts/classes.manageddockerforwarding.js +128 -0
  9. package/dist_ts/classes.managednftables.d.ts +9 -7
  10. package/dist_ts/classes.managednftables.js +2 -2
  11. package/dist_ts/index.d.ts +3 -0
  12. package/dist_ts/index.js +3 -1
  13. package/dist_ts/managed.docker.types.d.ts +78 -0
  14. package/dist_ts/managed.docker.types.js +2 -0
  15. package/dist_ts/managed.egress.types.d.ts +91 -0
  16. package/dist_ts/managed.egress.types.js +2 -0
  17. package/dist_ts/managed.types.d.ts +20 -18
  18. package/package.json +2 -2
  19. package/readme.md +143 -6
  20. package/rust/src/docker.frontend.rs +203 -0
  21. package/rust/src/docker.graph.rs +129 -0
  22. package/rust/src/docker.policy.rs +140 -0
  23. package/rust/src/docker.rs +191 -0
  24. package/rust/src/docker_process_tests.rs +29 -0
  25. package/rust/src/docker_tests.rs +148 -0
  26. package/rust/src/egress.compile.rs +288 -0
  27. package/rust/src/egress.host.rs +103 -0
  28. package/rust/src/egress.router.rs +231 -0
  29. package/rust/src/egress.rs +682 -0
  30. package/rust/src/egress_tests.rs +332 -0
  31. package/rust/src/main.rs +13 -6
  32. package/rust/src/managed.rs +105 -0
  33. package/rust/src/owner.rs +115 -7
  34. package/rust/src/owner_coexistence_tests.rs +77 -0
  35. package/rust/src/owner_egress_identity_tests.rs +155 -0
  36. package/rust/src/owner_egress_tests.rs +153 -0
  37. package/rust/src/owner_egress_traffic_tests.rs +470 -0
  38. package/rust/src/owner_host_traffic_tests.rs +305 -0
  39. package/rust/src/owner_identity_tests.rs +169 -0
  40. package/rust/src/owner_link_tests.rs +119 -0
  41. package/rust/src/owner_packet_fixture.rs +200 -0
  42. package/rust/src/owner_tests.rs +33 -7
  43. package/rust/src/policy.rs +120 -81
  44. package/rust/src/tests.rs +22 -0
  45. package/rust/src/wire.links.rs +279 -0
  46. package/rust/src/wire.rs +16 -66
  47. package/ts/00_commitinfo_data.ts +1 -1
  48. package/ts/classes.manageddockerforwarding.ts +113 -0
  49. package/ts/classes.managednftables.ts +13 -13
  50. package/ts/index.ts +3 -0
  51. package/ts/managed.docker.types.ts +49 -0
  52. package/ts/managed.egress.types.ts +85 -0
  53. package/ts/managed.types.ts +20 -16
package/rust/src/owner.rs CHANGED
@@ -1,4 +1,5 @@
1
- use crate::policy::{identifier, Prepared};
1
+ use crate::managed::Prepared;
2
+ use crate::policy::identifier;
2
3
  use crate::wire::{self, Attr, Socket};
3
4
  use crate::{Error, Result};
4
5
  use serde::{Deserialize, Serialize};
@@ -69,7 +70,7 @@ pub struct Owner {
69
70
  released: bool,
70
71
  }
71
72
 
72
- fn context() -> Result<(String, String, String)> {
73
+ pub(crate) fn context() -> Result<(String, String, String)> {
73
74
  let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
74
75
  .map_err(|_| Error::Unavailable)?
75
76
  .trim()
@@ -111,6 +112,24 @@ fn kernel_floor() -> Result<()> {
111
112
  }
112
113
 
113
114
  impl Owner {
115
+ /// Read another dedicated owner's exact receipt without claiming its table.
116
+ /// PERSIST may retain enforcement after that owner's socket closes.
117
+ pub(crate) fn verify_external(expected: &Applied) -> Result<()> {
118
+ let identity = &expected.receipt.identity;
119
+ let mut reader = Self::new(Options {
120
+ owner_id: identity.owner_id.clone(),
121
+ instance_id: identity.instance_id.clone(),
122
+ table_name: identity.table_name.clone(),
123
+ })?;
124
+ reader.validate_receipt(expected)?;
125
+ let graph = reader.graph()?.ok_or(Error::Conflict)?;
126
+ reader.binding(&graph, Some(expected))?;
127
+ if !reader.matches(&graph, &expected.prepared)? {
128
+ return Err(Error::Conflict);
129
+ }
130
+ expected.prepared.validate_interfaces()
131
+ }
132
+
114
133
  pub fn new(options: Options) -> Result<Self> {
115
134
  if !identifier(&options.owner_id)
116
135
  || !identifier(&options.instance_id)
@@ -171,7 +190,15 @@ impl Owner {
171
190
  Err(error) => return Err(error),
172
191
  _ => return Err(Error::Protocol),
173
192
  };
174
- let chains = socket.query(4, vec![Attr::string(1, &table_name)], true)?;
193
+ // Linux's NFT_MSG_GETCHAIN dump is family-wide: it ignores
194
+ // NFTA_CHAIN_TABLE. Select exact table identity before examining
195
+ // chain contents; another owner's chains are not our graph.
196
+ let mut chains = Vec::new();
197
+ for chain in socket.query(4, vec![Attr::string(1, &table_name)], true)? {
198
+ if wire::text(&chain, 1)? == table_name {
199
+ chains.push(chain);
200
+ }
201
+ }
175
202
  let rules = socket.query(7, vec![Attr::string(1, &table_name)], true)?;
176
203
  // The managed compiler never creates any of these. Foreign objects reject.
177
204
  for operation in [10, 19, 23] {
@@ -222,7 +249,7 @@ impl Owner {
222
249
  if handle == 0
223
250
  || handle.to_string() != expected.receipt.table_handle
224
251
  || expected.receipt.identity != self.identity
225
- || expected.receipt.revision != expected.prepared.policy.revision
252
+ || expected.receipt.revision != expected.prepared.policy.revision()
226
253
  || expected.receipt.digest != expected.prepared.digest
227
254
  {
228
255
  return Err(Error::Conflict);
@@ -316,7 +343,7 @@ impl Owner {
316
343
  receipt: Receipt {
317
344
  identity: self.identity.clone(),
318
345
  table_handle: wire::handle(&graph.table, 4)?.to_string(),
319
- revision: prepared.policy.revision,
346
+ revision: prepared.policy.revision(),
320
347
  digest: prepared.digest.clone(),
321
348
  },
322
349
  prepared,
@@ -334,7 +361,9 @@ impl Owner {
334
361
  }
335
362
  if let Some(previous) = &transition.previous {
336
363
  self.validate_receipt(previous)?;
337
- if transition.target.policy.revision <= previous.prepared.policy.revision {
364
+ if transition.target.policy.kind() != previous.prepared.policy.kind()
365
+ || transition.target.policy.revision() <= previous.prepared.policy.revision()
366
+ {
338
367
  return Err(Error::Conflict);
339
368
  }
340
369
  }
@@ -568,11 +597,13 @@ fn rule_identity(attributes: &[Attr]) -> Result<(String, Vec<u8>)> {
568
597
  }
569
598
  let name = wire::text(&fields, 1)?;
570
599
  let mut data = wire::attrs(&wire::one(&fields, 2)?.value)?;
600
+ validate_egress_expression(&name, &data)?;
571
601
  let nested: &[u16] = match name.as_str() {
572
602
  "immediate" => &[2],
573
603
  "cmp" => &[3],
574
604
  "bitwise" => &[4, 5],
575
- "meta" | "payload" => &[],
605
+ "range" => &[3, 4],
606
+ "meta" | "payload" | "ct" | "nat" => &[],
576
607
  _ => return Err(Error::Conflict),
577
608
  };
578
609
  for attribute in &mut data {
@@ -599,6 +630,83 @@ fn rule_identity(attributes: &[Attr]) -> Result<(String, Vec<u8>)> {
599
630
  Ok((chain, encoded))
600
631
  }
601
632
 
633
+ /// These expressions are compiler-owned. Preserve all operands in graph identity;
634
+ /// neither unknown fields nor default repair may hide a changed kernel rule.
635
+ fn validate_egress_expression(name: &str, data: &[Attr]) -> Result<()> {
636
+ let allowed: &[u16] = match name {
637
+ "ct" | "range" => &[1, 2, 3, 4],
638
+ "nat" => &[1, 2, 3, 4, 5, 6, 7],
639
+ _ => return Ok(()),
640
+ };
641
+ for attribute in data {
642
+ if !allowed.contains(&attribute.id()) {
643
+ return Err(Error::Conflict);
644
+ }
645
+ wire::one(data, attribute.id())?; // Duplicate fields are never canonical identity.
646
+ }
647
+ match name {
648
+ "ct" => {
649
+ let read = data.iter().any(|item| item.id() == 1);
650
+ let write = data.iter().any(|item| item.id() == 4);
651
+ if read == write {
652
+ return Err(Error::Conflict);
653
+ }
654
+ wire::number(data, if read { 1 } else { 4 })?;
655
+ wire::number(data, 2)?;
656
+ if data.iter().any(|item| item.id() == 3)
657
+ && ![vec![0], vec![1]].contains(&wire::one(data, 3)?.value)
658
+ {
659
+ return Err(Error::Conflict);
660
+ }
661
+ }
662
+ "nat" => {
663
+ // Emit min/max registers and derived flags explicitly, just as Linux
664
+ // dumps them. This surface only compiles address-bearing IPv4 NAT.
665
+ for id in [1, 2, 3, 4, 7] {
666
+ wire::number(data, id)?;
667
+ }
668
+ if wire::number(data, 1)? > 1 || wire::number(data, 2)? != 2 {
669
+ return Err(Error::Conflict);
670
+ }
671
+ let first_port = data.iter().any(|item| item.id() == 5);
672
+ let last_port = data.iter().any(|item| item.id() == 6);
673
+ if first_port != last_port {
674
+ return Err(Error::Conflict);
675
+ }
676
+ if first_port {
677
+ wire::number(data, 5)?;
678
+ wire::number(data, 6)?;
679
+ }
680
+ }
681
+ "range" => {
682
+ wire::number(data, 1)?;
683
+ if wire::number(data, 2)? > 1 || data.len() != 4 {
684
+ return Err(Error::Conflict);
685
+ }
686
+ let mut length = None;
687
+ for id in [3, 4] {
688
+ let value = wire::attrs(&wire::one(data, id)?.value)?;
689
+ if value.len() != 1 {
690
+ return Err(Error::Conflict);
691
+ }
692
+ let bytes = &wire::one(&value, 1)?.value;
693
+ if !(1..=16).contains(&bytes.len())
694
+ || length.is_some_and(|size| size != bytes.len())
695
+ {
696
+ return Err(Error::Conflict);
697
+ }
698
+ length = Some(bytes.len());
699
+ }
700
+ }
701
+ _ => unreachable!(),
702
+ }
703
+ Ok(())
704
+ }
705
+
706
+ #[cfg(test)]
707
+ #[path = "owner_identity_tests.rs"]
708
+ mod identity_tests;
709
+
602
710
  #[cfg(test)]
603
711
  #[path = "owner_tests.rs"]
604
712
  mod native_tests;
@@ -0,0 +1,77 @@
1
+ use super::*;
2
+
3
+ #[test]
4
+ #[ignore = "requires disposable isolated native qualification guest"]
5
+ fn owner_scopes_family_wide_chain_dump_and_retains_foreign_table() {
6
+ isolated();
7
+ let mut foreign = Socket::open().unwrap();
8
+ let generation = foreign.generation().unwrap();
9
+ foreign
10
+ .batch(
11
+ generation,
12
+ vec![
13
+ (
14
+ 0,
15
+ 0x600,
16
+ vec![Attr::string(1, "foreign_table"), Attr::u32(2, 6)],
17
+ ),
18
+ (
19
+ 3,
20
+ 0x600,
21
+ vec![Attr::string(1, "foreign_table"), Attr::string(3, "input")],
22
+ ),
23
+ (
24
+ 3,
25
+ 0x600,
26
+ vec![
27
+ Attr::string(1, "foreign_table"),
28
+ Attr::string(3, "foreign_chain"),
29
+ ],
30
+ ),
31
+ ],
32
+ )
33
+ .unwrap();
34
+ let before = foreign
35
+ .query(4, vec![Attr::string(1, "foreign_table")], true)
36
+ .unwrap();
37
+ assert_eq!(before.len(), 2);
38
+ let mut owner = Owner::new(options("coexistence")).unwrap();
39
+ let intent = Transition {
40
+ previous: None,
41
+ target: empty(1),
42
+ };
43
+ let applied = owner.reconcile(intent.clone()).unwrap();
44
+ assert!(owner.inspect().enforced);
45
+ // Prove the kernel ignores the requested chain table filter.
46
+ assert_eq!(
47
+ foreign
48
+ .query(4, vec![Attr::string(1, "foreign_table")], true)
49
+ .unwrap()
50
+ .len(),
51
+ 5
52
+ );
53
+ assert_eq!(owner.graph().unwrap().unwrap().chains.len(), 3);
54
+ drop(owner);
55
+ let mut owner = Owner::new(options("coexistence")).unwrap();
56
+ assert_eq!(owner.reconcile(intent).unwrap(), applied);
57
+ let applied = owner
58
+ .reconcile(Transition {
59
+ previous: Some(applied),
60
+ target: empty(2),
61
+ })
62
+ .unwrap();
63
+ owner.release(applied).unwrap();
64
+ assert_eq!(
65
+ foreign
66
+ .query(4, vec![Attr::string(1, "foreign_table")], true)
67
+ .unwrap(),
68
+ before
69
+ );
70
+ let generation = foreign.generation().unwrap();
71
+ foreign
72
+ .batch(
73
+ generation,
74
+ vec![(2, 0, vec![Attr::string(1, "foreign_table")])],
75
+ )
76
+ .unwrap();
77
+ }
@@ -0,0 +1,155 @@
1
+ use super::isolated;
2
+ use crate::owner::{chain_identity, rule_identity};
3
+ use crate::wire::{self, Attr, Socket};
4
+
5
+ fn expression(name: &str, data: Vec<Attr>) -> Attr {
6
+ Attr::nested(1, vec![Attr::string(1, name), Attr::nested(2, data)])
7
+ }
8
+ fn immediate(register: u32, value: Vec<u8>) -> Attr {
9
+ expression(
10
+ "immediate",
11
+ vec![
12
+ Attr::u32(1, register),
13
+ Attr::nested(2, vec![Attr::bytes(1, value)]),
14
+ ],
15
+ )
16
+ }
17
+ fn chain(name: &str, hook: u32, priority: i32, kind: &str) -> Vec<Attr> {
18
+ vec![
19
+ Attr::string(1, "snft_egress_identity"),
20
+ Attr::string(3, name),
21
+ Attr::nested(4, vec![Attr::u32(1, hook), Attr::u32(2, priority as u32)]),
22
+ Attr::u32(5, 1),
23
+ Attr::string(7, kind),
24
+ Attr::u32(10, 1),
25
+ ]
26
+ }
27
+ fn rule(chain: &str, ordinal: u8, expressions: Vec<Attr>) -> Vec<Attr> {
28
+ vec![
29
+ Attr::string(1, "snft_egress_identity"),
30
+ Attr::string(2, chain),
31
+ Attr::nested(4, expressions),
32
+ Attr::bytes(7, vec![ordinal]),
33
+ ]
34
+ }
35
+
36
+ #[test]
37
+ #[ignore = "requires the explicitly isolated QEMU qualification guest"]
38
+ fn native_ct_nat_range_graph_identity_matches_exact_linux_dump() {
39
+ isolated();
40
+ let chains = [
41
+ chain("raw", 0, -300, "filter"),
42
+ chain("forward", 2, 0, "filter"),
43
+ chain("post", 4, 100, "nat"),
44
+ ];
45
+ let rules = [
46
+ rule(
47
+ "raw",
48
+ 1,
49
+ vec![
50
+ immediate(1, 17_u16.to_ne_bytes().to_vec()),
51
+ expression("ct", vec![Attr::u32(2, 17), Attr::u32(4, 1)]),
52
+ ],
53
+ ),
54
+ rule(
55
+ "forward",
56
+ 2,
57
+ vec![
58
+ expression(
59
+ "ct",
60
+ vec![Attr::u32(1, 1), Attr::u32(2, 20), Attr::bytes(3, vec![0])],
61
+ ),
62
+ expression("ct", vec![Attr::u32(1, 1), Attr::u32(2, 17)]),
63
+ immediate(1, vec![0x7a; 16]),
64
+ expression("ct", vec![Attr::u32(2, 13), Attr::u32(4, 1)]),
65
+ expression("ct", vec![Attr::u32(1, 1), Attr::u32(2, 13)]),
66
+ expression(
67
+ "payload",
68
+ vec![
69
+ Attr::u32(1, 1),
70
+ Attr::u32(2, 2),
71
+ Attr::u32(3, 0),
72
+ Attr::u32(4, 2),
73
+ ],
74
+ ),
75
+ expression(
76
+ "range",
77
+ vec![
78
+ Attr::u32(1, 1),
79
+ Attr::u32(2, 0),
80
+ Attr::nested(3, vec![Attr::bytes(1, 10000_u16.to_be_bytes().to_vec())]),
81
+ Attr::nested(4, vec![Attr::bytes(1, 10999_u16.to_be_bytes().to_vec())]),
82
+ ],
83
+ ),
84
+ ],
85
+ ),
86
+ rule(
87
+ "post",
88
+ 3,
89
+ vec![
90
+ immediate(1, vec![10, 240, 0, 2]),
91
+ immediate(2, 10000_u16.to_be_bytes().to_vec()),
92
+ immediate(3, 10999_u16.to_be_bytes().to_vec()),
93
+ expression(
94
+ "nat",
95
+ vec![
96
+ Attr::u32(1, 0),
97
+ Attr::u32(2, 2),
98
+ Attr::u32(3, 1),
99
+ Attr::u32(4, 1),
100
+ Attr::u32(5, 2),
101
+ Attr::u32(6, 3),
102
+ Attr::u32(7, 3),
103
+ ],
104
+ ),
105
+ ],
106
+ ),
107
+ ];
108
+ let mut socket = Socket::open().unwrap();
109
+ let mut operations = vec![(
110
+ 0,
111
+ 0x600,
112
+ vec![Attr::string(1, "snft_egress_identity"), Attr::u32(2, 6)],
113
+ )];
114
+ operations.extend(chains.iter().cloned().map(|value| (3, 0x600, value)));
115
+ operations.extend(rules.iter().cloned().map(|value| (6, 0xc00, value)));
116
+ let generation = socket.generation().unwrap();
117
+ socket.batch(generation, operations).unwrap();
118
+ let dumped_chains = socket
119
+ .query(4, vec![Attr::string(1, "snft_egress_identity")], true)
120
+ .unwrap();
121
+ assert_eq!(dumped_chains.len(), chains.len());
122
+ for expected in &chains {
123
+ let name = wire::text(expected, 3).unwrap();
124
+ let actual = dumped_chains
125
+ .iter()
126
+ .find(|item| wire::text(item, 3).unwrap() == name)
127
+ .unwrap();
128
+ assert_eq!(
129
+ chain_identity(actual).unwrap(),
130
+ chain_identity(expected).unwrap()
131
+ );
132
+ }
133
+ let dumped_rules = socket
134
+ .query(7, vec![Attr::string(1, "snft_egress_identity")], true)
135
+ .unwrap();
136
+ assert_eq!(dumped_rules.len(), rules.len());
137
+ for expected in &rules {
138
+ let actual = dumped_rules
139
+ .iter()
140
+ .find(|item| wire::one(item, 7).unwrap().value == wire::one(expected, 7).unwrap().value)
141
+ .unwrap();
142
+ assert_eq!(
143
+ rule_identity(actual).unwrap(),
144
+ rule_identity(expected).unwrap()
145
+ );
146
+ }
147
+ let generation = socket.generation().unwrap();
148
+ socket
149
+ .batch(
150
+ generation,
151
+ vec![(2, 0, vec![Attr::string(1, "snft_egress_identity")])],
152
+ )
153
+ .unwrap();
154
+ println!("EGRESS_EXPRESSION_IDENTITY ct_zone_label_original_ipv4=true nat_explicit_min_max=true range_inclusive=true traffic_qualified=false");
155
+ }
@@ -0,0 +1,153 @@
1
+ use super::*;
2
+ use crate::egress;
3
+ use serde_json::{json, Value};
4
+
5
+ fn binding(name: &str, address: &str, kind: &str) -> Value {
6
+ let output = std::process::Command::new("/bin/ip")
7
+ .args(["-j", "-d", "link", "show", "dev", name])
8
+ .output()
9
+ .unwrap();
10
+ assert!(output.status.success());
11
+ let observed = serde_json::from_slice::<Value>(&output.stdout).unwrap()[0].clone();
12
+ let index = observed["ifindex"].as_u64().unwrap();
13
+ let peer_index = if kind == "veth" {
14
+ let peer = std::ffi::CString::new(format!("{name}peer")).unwrap();
15
+ let index = unsafe { libc::if_nametoindex(peer.as_ptr()) };
16
+ assert_ne!(index, 0);
17
+ u64::from(index)
18
+ } else {
19
+ index
20
+ };
21
+ json!({"interfaceIndex":index,"interfaceName":name,"interfaceKind":kind,
22
+ "macAddress":if kind=="tun" {Value::Null} else {observed["address"].clone()},
23
+ "interfaceLinkIndex":peer_index,
24
+ "requiredIpv4Addresses":[address]})
25
+ }
26
+ fn veth(name: &str, address: &str) {
27
+ ip(&[
28
+ "link",
29
+ "add",
30
+ name,
31
+ "type",
32
+ "veth",
33
+ "peer",
34
+ "name",
35
+ &format!("{name}peer"),
36
+ ]);
37
+ ip(&["address", "add", address, "dev", name]);
38
+ ip(&["link", "set", name, "up"]);
39
+ }
40
+ fn router() -> egress::Policy {
41
+ veth("work", "10.241.0.1/24");
42
+ veth("handoff", "10.240.0.2/30");
43
+ ip(&["tuntap", "add", "dev", "vpn", "mode", "tun"]);
44
+ ip(&["address", "add", "10.242.0.1/24", "dev", "vpn"]);
45
+ let mut value = egress::tests::router();
46
+ value["scope"]["links"] = json!([
47
+ binding("work", "10.241.0.1", "veth"),
48
+ binding("vpn", "10.242.0.1", "tun")
49
+ ]);
50
+ value["scope"]["handoff"] = binding("handoff", "10.240.0.2", "veth");
51
+ for index in 0..2 {
52
+ value["scope"]["endpoints"][index]["interfaceIndex"] =
53
+ value["scope"]["links"][index]["interfaceIndex"].clone();
54
+ }
55
+ serde_json::from_value(value).unwrap()
56
+ }
57
+ fn host() -> egress::Policy {
58
+ veth("handoff", "10.240.0.1/30");
59
+ veth("uplink", "192.0.2.2/24");
60
+ let mut value = egress::tests::host();
61
+ value["scope"]["handoffs"][0]["link"] = binding("handoff", "10.240.0.1", "veth");
62
+ value["scope"]["uplink"] = binding("uplink", "192.0.2.2", "veth");
63
+ serde_json::from_value(value).unwrap()
64
+ }
65
+ fn recover(name: &str, policy: egress::Policy) {
66
+ let prepared: Prepared = policy.clone().prepare().unwrap().into();
67
+ prepared.validate_interfaces().expect("fixture bindings");
68
+ let mut owner = Owner::new(options(name)).unwrap();
69
+ let result = owner.reconcile(Transition {
70
+ previous: None,
71
+ target: prepared.clone(),
72
+ });
73
+ if result.is_err() {
74
+ if let Some(graph) = owner.graph().unwrap() {
75
+ for (kind, expected) in prepared.program(&format!("snft_{name}")).unwrap() {
76
+ if kind == 3 {
77
+ let name = wire::text(&expected, 3).unwrap();
78
+ let actual = graph
79
+ .chains
80
+ .iter()
81
+ .find(|item| wire::text(item, 3).unwrap() == name)
82
+ .unwrap();
83
+ assert_eq!(
84
+ chain_identity(&expected),
85
+ chain_identity(actual),
86
+ "chain {name}"
87
+ );
88
+ } else {
89
+ let stamp = &wire::one(&expected, 7).unwrap().value;
90
+ let actual = graph
91
+ .rules
92
+ .iter()
93
+ .find(|item| &wire::one(item, 7).unwrap().value == stamp)
94
+ .unwrap();
95
+ if rule_identity(&expected) != rule_identity(actual) {
96
+ println!("expected_rule={expected:?}\nactual_rule={actual:?}");
97
+ panic!("full graph identity differs");
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ let applied = result.unwrap();
104
+ assert!(owner.inspect().enforced);
105
+ let handle = applied.receipt.table_handle.clone();
106
+ // Drop only the process/socket owner. PERSIST retains the complete graph.
107
+ drop(owner);
108
+ let mut owner = Owner::new(options(name)).unwrap();
109
+ let recovered = owner
110
+ .reconcile(Transition {
111
+ previous: None,
112
+ target: prepared,
113
+ })
114
+ .unwrap();
115
+ assert_eq!(recovered.receipt.table_handle, handle);
116
+ assert!(owner.inspect().enforced);
117
+ let mut next = policy;
118
+ next.revision += 1;
119
+ let transition = Transition {
120
+ previous: Some(recovered.clone()),
121
+ target: next.prepare().unwrap().into(),
122
+ };
123
+ let next_applied = owner.reconcile(transition.clone()).unwrap();
124
+ assert!(owner.inspect().enforced);
125
+ // Exact persisted command replay after the target ACK was lost.
126
+ drop(owner);
127
+ let mut owner = Owner::new(options(name)).unwrap();
128
+ assert_eq!(owner.reconcile(transition).unwrap(), next_applied);
129
+ assert!(owner.inspect().enforced);
130
+ // A valid private-v1 policy cannot replace this table's v2 role.
131
+ assert_eq!(
132
+ owner.reconcile(Transition {
133
+ previous: Some(next_applied.clone()),
134
+ target: empty(3)
135
+ }),
136
+ Err(Error::Conflict)
137
+ );
138
+ assert!(owner.inspect().enforced);
139
+ owner.release(next_applied).unwrap();
140
+ }
141
+
142
+ #[test]
143
+ #[ignore = "requires disposable isolated native qualification guest"]
144
+ fn v2_router_complete_graph_persist_and_lost_ack_recovery() {
145
+ isolated();
146
+ recover("router_v2", router());
147
+ }
148
+ #[test]
149
+ #[ignore = "requires disposable isolated native qualification guest"]
150
+ fn v2_host_complete_graph_persist_and_lost_ack_recovery() {
151
+ isolated();
152
+ recover("host_v2", host());
153
+ }