@push.rocks/smartnftables 2.1.0 → 2.3.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.
@@ -1,7 +1,10 @@
1
1
  use super::egress_traffic_tests::{connect, denied, host_policy, ns_ip, roundtrip, socket};
2
2
  use super::*;
3
+ use crate::egress;
3
4
  use crate::policy::{compare, expr, meta};
4
- use std::net::UdpSocket;
5
+ use std::io::{Read, Write};
6
+ use std::net::{TcpListener, TcpStream, UdpSocket};
7
+ use std::time::Duration;
5
8
 
6
9
  const SOURCE: &str = "hs_source";
7
10
  const HOST: &str = "hs_host";
@@ -297,3 +300,223 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
297
300
  });
298
301
  println!("V2_HOST_PROOF original_protected_dnat_denied=true current_protected_dnat_denied=true nonzero_zone_denied=true input_diversion_denied=true output_spoof_denied=true ipv6_forward_denied=true unleased_port_denied=true exact_platform_allowed=true positive_controls=true");
299
302
  }
303
+
304
+ const PUBLISH_SOURCE: &str = "hp_source";
305
+ const PUBLISH_HOST: &str = "hp_host";
306
+ const PUBLISH_PEER: &str = "hp_peer";
307
+
308
+ fn chain_names(owner: &mut Owner) -> Vec<String> {
309
+ owner
310
+ .graph()
311
+ .unwrap()
312
+ .expect("owned table present")
313
+ .chains
314
+ .iter()
315
+ .map(|chain| wire::text(chain, 3).unwrap())
316
+ .collect()
317
+ }
318
+ /// Inbound publication through the uplink: the workload must observe the actual
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) {
321
+ let mut bytes = [0; 64];
322
+ assert_eq!(client.send_to(b"query", published).unwrap(), 5);
323
+ let (size, observed) = workload.recv_from(&mut bytes).expect("published delivery");
324
+ assert_eq!(&bytes[..size], b"query");
325
+ assert_eq!(observed.to_string(), client_address);
326
+ assert_eq!(workload.send_to(b"reply", observed).unwrap(), 5);
327
+ let (size, from) = client.recv_from(&mut bytes).expect("published reply");
328
+ assert_eq!(&bytes[..size], b"reply");
329
+ assert_eq!(from.to_string(), published);
330
+ }
331
+ fn dark_udp(client: &UdpSocket, workload: &UdpSocket, published: &str) {
332
+ assert_eq!(client.send_to(b"query", published).unwrap(), 5);
333
+ assert!(workload.recv(&mut [0; 64]).is_err());
334
+ }
335
+
336
+ #[test]
337
+ #[ignore = "requires disposable isolated native qualification guest"]
338
+ fn v2_host_publishes_exact_uplink_ports_and_removes_them_with_the_generation() {
339
+ isolated();
340
+ for name in [PUBLISH_SOURCE, PUBLISH_HOST, PUBLISH_PEER] {
341
+ ip(&["netns", "add", name]);
342
+ ns_ip(name, &["link", "set", "lo", "up"]);
343
+ in_namespace(name, || {
344
+ std::fs::write("/proc/sys/net/ipv4/ip_forward", "1").unwrap()
345
+ });
346
+ }
347
+ connect(
348
+ PUBLISH_SOURCE,
349
+ "source",
350
+ "10.240.0.2/30",
351
+ PUBLISH_HOST,
352
+ "router",
353
+ "10.240.0.1/30",
354
+ );
355
+ connect(
356
+ PUBLISH_HOST,
357
+ "uplink",
358
+ "192.0.2.2/24",
359
+ PUBLISH_PEER,
360
+ "underlay",
361
+ "192.0.2.3/24",
362
+ );
363
+ ns_ip(PUBLISH_SOURCE, &["route", "add", "default", "via", "10.240.0.1"]);
364
+ ns_ip(PUBLISH_PEER, &["route", "add", "default", "via", "192.0.2.2"]);
365
+ let workload_udp = socket(PUBLISH_SOURCE, "10.240.0.2:8125");
366
+ let workload_direct = socket(PUBLISH_SOURCE, "10.240.0.2:8444");
367
+ let workload_unpublished = socket(PUBLISH_SOURCE, "10.240.0.2:9999");
368
+ // Every probe uses its own client port: an unreplied conntrack entry would
369
+ // otherwise carry its null translation across the policy change.
370
+ let control_client = socket(PUBLISH_PEER, "192.0.2.3:40010");
371
+ let client_udp = socket(PUBLISH_PEER, "192.0.2.3:40000");
372
+ // Positive controls: without policy the workload is reachable directly, and
373
+ // the published address is not, so every later result is caused by the policy.
374
+ control(&control_client, "10.240.0.2:8444", &workload_direct, true);
375
+ dark_udp(
376
+ &socket(PUBLISH_PEER, "192.0.2.3:40011"),
377
+ &workload_udp,
378
+ "192.0.2.2:8125",
379
+ );
380
+ let (owner, applied, published_policy, names) = in_namespace(PUBLISH_HOST, || {
381
+ let mut policy = host_policy();
382
+ let egress::Scope::HostTransit(scope) = &mut policy.scope else {
383
+ panic!()
384
+ };
385
+ scope.published_ports = vec![
386
+ egress::PublishedPort {
387
+ protocol: "tcp".into(),
388
+ host_port: 443,
389
+ target_port: 8443,
390
+ target_address: "10.240.0.2".into(),
391
+ host_ip: "192.0.2.2".into(),
392
+ },
393
+ egress::PublishedPort {
394
+ protocol: "udp".into(),
395
+ host_port: 8125,
396
+ target_port: 8125,
397
+ target_address: "10.240.0.2".into(),
398
+ host_ip: "192.0.2.2".into(),
399
+ },
400
+ ];
401
+ let mut owner = Owner::new(options("host_published")).unwrap();
402
+ let target: Prepared = policy.clone().prepare().unwrap().into();
403
+ target
404
+ .validate_interfaces()
405
+ .expect("published host fixture binding");
406
+ let applied = owner
407
+ .reconcile(Transition {
408
+ previous: None,
409
+ target,
410
+ })
411
+ .unwrap();
412
+ assert!(owner.inspect().enforced);
413
+ let names = chain_names(&mut owner);
414
+ (owner, applied, policy, names)
415
+ });
416
+ assert!(names.contains(&"pre".to_string()));
417
+ // The fixture observes ingress only, so the translated opening is read where
418
+ // it arrives: the workload side of the handoff.
419
+ let observer = in_namespace(PUBLISH_SOURCE, || packet_fixture::PacketSocket::open("source"));
420
+ published_udp(&client_udp, &workload_udp, "192.0.2.2:8125", "192.0.2.3:40000");
421
+ let listener = in_namespace(PUBLISH_SOURCE, || {
422
+ TcpListener::bind("10.240.0.2:8443").unwrap()
423
+ });
424
+ let mut client = in_namespace(PUBLISH_PEER, || {
425
+ TcpStream::connect_timeout(&"192.0.2.2:443".parse().unwrap(), Duration::from_secs(1))
426
+ .expect("published tcp opening")
427
+ });
428
+ let (mut server, peer) = listener.accept().unwrap();
429
+ assert_eq!(peer.ip().to_string(), "192.0.2.3");
430
+ assert_eq!(client.peer_addr().unwrap().to_string(), "192.0.2.2:443");
431
+ for stream in [&client, &server] {
432
+ stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
433
+ }
434
+ client.write_all(b"ping").unwrap();
435
+ server.read_exact(&mut [0; 4]).unwrap();
436
+ server.write_all(b"pong").unwrap();
437
+ client.read_exact(&mut [0; 4]).unwrap();
438
+ // The translated opening is observable on the handoff, with the actual client
439
+ // address preserved and SYN only.
440
+ let syn = observer
441
+ .matching(|packet| {
442
+ packet.len() >= 40
443
+ && packet[9] == 6
444
+ && packet[16..20] == [10, 240, 0, 2]
445
+ && packet[22..24] == 8443_u16.to_be_bytes()
446
+ && packet[33] & 0x17 == 0x02
447
+ })
448
+ .expect("translated opening on the handoff");
449
+ assert_eq!(syn[12..16], [192, 0, 2, 3]);
450
+ // An opening that is not SYN cannot acquire the publication.
451
+ let injector = in_namespace(PUBLISH_PEER, || {
452
+ packet_fixture::PacketSocket::open("underlay")
453
+ });
454
+ let uplink_mac = in_namespace(PUBLISH_HOST, || packet_fixture::mac("uplink"));
455
+ for (port, flags) in [(40050_u16, 0x10_u8), (40051, 0x04), (40052, 0x12)] {
456
+ injector.send(
457
+ uplink_mac,
458
+ &packet_fixture::tcp([192, 0, 2, 3], [192, 0, 2, 2], port, 443, flags),
459
+ );
460
+ // The exact injected flow, never the live connection's own acknowledgements.
461
+ assert!(
462
+ observer
463
+ .matching(|packet| packet.len() >= 40
464
+ && packet[9] == 6
465
+ && packet[16..20] == [10, 240, 0, 2]
466
+ && packet[20..22] == port.to_be_bytes()
467
+ && packet[22..24] == 8443_u16.to_be_bytes()
468
+ && packet[24..28] == 100_u32.to_be_bytes()
469
+ && packet[33] == flags)
470
+ .is_none(),
471
+ "{flags:#04x}"
472
+ );
473
+ }
474
+ // The capture barrier still denies every unpublished path to the workload.
475
+ control(&control_client, "10.240.0.2:8444", &workload_direct, false);
476
+ let unpublished_client = socket(PUBLISH_PEER, "192.0.2.3:40002");
477
+ dark_udp(&unpublished_client, &workload_unpublished, "192.0.2.2:9999");
478
+ assert!(in_namespace(PUBLISH_PEER, || TcpStream::connect_timeout(
479
+ &"192.0.2.2:444".parse().unwrap(),
480
+ Duration::from_secs(1)
481
+ ))
482
+ .is_err());
483
+ drop(client);
484
+ drop(server);
485
+ // Withdrawing the publication is an ordinary generation replacement.
486
+ let (mut owner, rolled_back, names) = in_namespace(PUBLISH_HOST, {
487
+ let mut owner = owner;
488
+ move || {
489
+ let mut withdrawn = published_policy;
490
+ withdrawn.revision = 2;
491
+ let egress::Scope::HostTransit(scope) = &mut withdrawn.scope else {
492
+ panic!()
493
+ };
494
+ scope.published_ports.clear();
495
+ let applied = owner
496
+ .reconcile(Transition {
497
+ previous: Some(applied),
498
+ target: withdrawn.prepare().unwrap().into(),
499
+ })
500
+ .unwrap();
501
+ assert!(owner.inspect().enforced);
502
+ let names = chain_names(&mut owner);
503
+ (owner, applied, names)
504
+ }
505
+ });
506
+ assert!(!names.contains(&"pre".to_string()));
507
+ let dark_client = socket(PUBLISH_PEER, "192.0.2.3:40003");
508
+ dark_udp(&dark_client, &workload_udp, "192.0.2.2:8125");
509
+ assert!(in_namespace(PUBLISH_PEER, || TcpStream::connect_timeout(
510
+ &"192.0.2.2:443".parse().unwrap(),
511
+ Duration::from_secs(1)
512
+ ))
513
+ .is_err());
514
+ control(&control_client, "10.240.0.2:8444", &workload_direct, false);
515
+ in_namespace(PUBLISH_HOST, move || {
516
+ assert!(owner.inspect().enforced);
517
+ owner.release(rolled_back).unwrap();
518
+ });
519
+ let released_client = socket(PUBLISH_PEER, "192.0.2.3:40004");
520
+ dark_udp(&released_client, &workload_udp, "192.0.2.2:8125");
521
+ println!("V2_HOST_PUBLISHED_PROOF udp_published_delivered=true tcp_published_delivered=true client_address_preserved=true reverse_translation=true syn_only_opening=true nonsyn_opening_denied=true unpublished_workload_port_denied=true unpublished_uplink_port_denied=true barrier_positive_control=true withdrawn_chain_absent=true withdrawn_port_dark=true released_port_dark=true");
522
+ }
@@ -167,3 +167,40 @@ fn egress_identity_rejects_unknown_duplicate_or_incomplete_operands() {
167
167
  assert!(rule_identity(&rule(vec![expression(name, extra)])).is_err());
168
168
  }
169
169
  }
170
+
171
+ /// A published host-transit generation must survive the same apply-time graph
172
+ /// identity as the rest of the table: the kernel dump is compared operand by
173
+ /// operand, so destination NAT and its admissions cannot be silently rewritten.
174
+ #[test]
175
+ fn published_host_graph_identity_preserves_destination_nat_and_admission_operands() {
176
+ let compiled = |target_port: u16| {
177
+ let value = crate::egress::tests::published_host(serde_json::json!([
178
+ crate::egress::tests::published_port("tcp", 443, target_port)
179
+ ]));
180
+ let prepared = serde_json::from_value::<crate::egress::Policy>(value)
181
+ .unwrap()
182
+ .prepare()
183
+ .unwrap();
184
+ let mut chains = Vec::new();
185
+ let mut rules = Vec::new();
186
+ for (kind, attributes) in prepared.program("snft_identity").unwrap() {
187
+ if kind == 3 {
188
+ chains.push(super::chain_identity(&attributes).unwrap());
189
+ } else {
190
+ rules.push(rule_identity(&attributes).unwrap());
191
+ }
192
+ }
193
+ (chains, rules)
194
+ };
195
+ let (chains, rules) = compiled(8443);
196
+ assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
197
+ assert_eq!(chains.len(), 7);
198
+ let translation = rules.iter().find(|(chain, _)| chain == "pre").unwrap();
199
+ let (_, changed) = compiled(9443);
200
+ let other = changed.iter().find(|(chain, _)| chain == "pre").unwrap();
201
+ assert_ne!(translation.1, other.1);
202
+ assert_eq!(
203
+ rules.iter().filter(|(chain, _)| chain == "forward").count(),
204
+ changed.iter().filter(|(chain, _)| chain == "forward").count()
205
+ );
206
+ }
@@ -27,6 +27,9 @@ mod poolguard_tests;
27
27
  #[path = "owner_detach_tests.rs"]
28
28
  mod detach_tests;
29
29
 
30
+ #[path = "owner_transitionrelease_tests.rs"]
31
+ mod transitionrelease_tests;
32
+
30
33
  #[path = "owner_coexistence_tests.rs"]
31
34
  mod coexistence_tests;
32
35
 
@@ -0,0 +1,327 @@
1
+ use super::namespace_tests::NativeOwner;
2
+ use super::*;
3
+ use serde_json::json;
4
+ use std::io::Write;
5
+ use std::time::{Duration, Instant};
6
+
7
+ fn target(revision: u64) -> Prepared {
8
+ let mut policy = crate::egress::tests::pool_guard();
9
+ policy["revision"] = json!(revision);
10
+ serde_json::from_value::<crate::egress::Policy>(policy)
11
+ .unwrap()
12
+ .prepare()
13
+ .unwrap()
14
+ .into()
15
+ }
16
+ fn request(owner: &Owner, transition: Transition) -> TransitionRelease {
17
+ TransitionRelease {
18
+ identity: owner.identity.clone(),
19
+ transition,
20
+ }
21
+ }
22
+
23
+ #[test]
24
+ fn transition_release_validates_closed_identity_and_body_before_admission() {
25
+ let mut owner = Owner::new(options("release_input")).unwrap();
26
+ let command = request(
27
+ &owner,
28
+ Transition {
29
+ previous: None,
30
+ target: target(1),
31
+ },
32
+ );
33
+ for field in ["bootId", "namespaceDevice", "namespaceInode", "instanceId"] {
34
+ let mut invalid = serde_json::to_value(&command).unwrap();
35
+ invalid["identity"][field] = json!("invalid");
36
+ assert_eq!(
37
+ owner.release_transition(serde_json::from_value(invalid).unwrap()),
38
+ Err(Error::Conflict)
39
+ );
40
+ assert!(owner.transition_release.is_none());
41
+ }
42
+ let mut invalid = command.clone();
43
+ invalid.transition.target.digest = format!("sha256:{}", "0".repeat(64));
44
+ assert!(owner.release_transition(invalid).is_err());
45
+ assert!(owner.transition_release.is_none());
46
+ for pointer in ["", "/identity", "/transition", "/transition/target"] {
47
+ let mut invalid = serde_json::to_value(&command).unwrap();
48
+ invalid.pointer_mut(pointer).unwrap()["extra"] = json!(true);
49
+ assert!(serde_json::from_value::<TransitionRelease>(invalid).is_err());
50
+ }
51
+ let mut invalid = serde_json::to_value(&command).unwrap();
52
+ invalid["transition"]
53
+ .as_object_mut()
54
+ .unwrap()
55
+ .remove("previous");
56
+ assert!(serde_json::from_value::<TransitionRelease>(invalid).is_err());
57
+ assert!(owner.socket.is_none());
58
+ }
59
+
60
+ #[test]
61
+ #[ignore = "requires disposable isolated native qualification guest"]
62
+ fn release_original_transition_after_interfaces_disappear_never_recreates_policy() {
63
+ isolated();
64
+ for replacement in [false, true] {
65
+ for delivered in [false, true] {
66
+ let endpoint = endpoint("a", 72);
67
+ let policy = crate::policy::Policy {
68
+ schema_version: 1,
69
+ revision: 1,
70
+ endpoints: vec![endpoint],
71
+ rules: vec![],
72
+ };
73
+ let mut owner = Owner::new(options("release_links")).unwrap();
74
+ let previous = replacement.then(|| {
75
+ owner
76
+ .reconcile(Transition {
77
+ previous: None,
78
+ target: policy.clone().prepare().unwrap().into(),
79
+ })
80
+ .unwrap()
81
+ });
82
+ let mut next = policy;
83
+ if replacement {
84
+ next.revision = 2;
85
+ }
86
+ let command = request(
87
+ &owner,
88
+ Transition {
89
+ previous,
90
+ target: next.prepare().unwrap().into(),
91
+ },
92
+ );
93
+ if delivered {
94
+ owner.reconcile(command.transition.clone()).unwrap();
95
+ }
96
+ // Only the original command crosses this process boundary.
97
+ drop(owner);
98
+ ip(&["link", "del", "worka"]);
99
+ ip(&["netns", "del", "a"]);
100
+ let mut fresh = Owner::new(options("release_links")).unwrap();
101
+ assert!(fresh.reconcile(command.transition.clone()).is_err());
102
+ if replacement && delivered {
103
+ // Model a retained native pending command after an ambiguous batch.
104
+ fresh.pending = Some(command.transition.clone());
105
+ }
106
+ fresh.release_transition(command.clone()).unwrap();
107
+ let status = fresh.inspect();
108
+ assert_eq!(status.state, "released");
109
+ assert!(!status.enforced);
110
+ assert!(fresh.graph().unwrap().is_none());
111
+ fresh.release_transition(command.clone()).unwrap();
112
+ assert_eq!(
113
+ fresh.reconcile(command.transition.clone()),
114
+ Err(Error::Conflict)
115
+ );
116
+ let mut different = command;
117
+ different.transition.target = target(3);
118
+ assert_eq!(fresh.release_transition(different), Err(Error::Conflict));
119
+ fresh.close().unwrap();
120
+ }
121
+ }
122
+ }
123
+
124
+ #[test]
125
+ #[ignore = "requires disposable isolated native qualification guest"]
126
+ fn release_transition_rejects_foreign_owner_and_keeps_exact_terminal_choice() {
127
+ isolated();
128
+ let mut active = Owner::new(options("release_foreign")).unwrap();
129
+ let command = request(
130
+ &active,
131
+ Transition {
132
+ previous: None,
133
+ target: target(1),
134
+ },
135
+ );
136
+ let applied = active.reconcile(command.transition.clone()).unwrap();
137
+ let mut fresh = Owner::new(options("release_foreign")).unwrap();
138
+ assert_eq!(
139
+ fresh.release_transition(command.clone()),
140
+ Err(Error::ForeignOwner)
141
+ );
142
+ assert_eq!(fresh.inspect().state, "failed-owned");
143
+ assert!(!fresh.inspect().enforced);
144
+ assert_eq!(
145
+ fresh.reconcile(command.transition.clone()),
146
+ Err(Error::Conflict)
147
+ );
148
+ assert_eq!(fresh.detach(applied.clone()), Err(Error::Conflict));
149
+ assert_eq!(fresh.release(applied), Err(Error::Conflict));
150
+ assert!(active.inspect().enforced);
151
+ drop(active);
152
+ // close may retry only the already-selected exact release operation.
153
+ fresh.close().unwrap();
154
+ assert!(fresh.graph().unwrap().is_none());
155
+ fresh.release_transition(command).unwrap();
156
+ }
157
+
158
+ #[test]
159
+ #[ignore = "requires disposable isolated native qualification guest"]
160
+ fn release_transition_binds_previous_handle_and_complete_graph() {
161
+ isolated();
162
+ for fault in ["handle", "body", "pending"] {
163
+ let mut active = Owner::new(options("release_binding")).unwrap();
164
+ let previous = active
165
+ .reconcile(Transition {
166
+ previous: None,
167
+ target: target(1),
168
+ })
169
+ .unwrap();
170
+ let command = request(
171
+ &active,
172
+ Transition {
173
+ previous: Some(previous.clone()),
174
+ target: target(2),
175
+ },
176
+ );
177
+ drop(active);
178
+ let mut fresh = Owner::new(options("release_binding")).unwrap();
179
+ let mut invalid = command.clone();
180
+ if fault == "handle" {
181
+ invalid
182
+ .transition
183
+ .previous
184
+ .as_mut()
185
+ .unwrap()
186
+ .receipt
187
+ .table_handle = "18446744073709551615".into();
188
+ } else if fault == "pending" {
189
+ fresh.pending = Some(Transition {
190
+ previous: Some(previous.clone()),
191
+ target: target(3),
192
+ });
193
+ } else {
194
+ let mut foreign = Socket::open().unwrap();
195
+ let generation = foreign.generation().unwrap();
196
+ foreign
197
+ .batch(
198
+ generation,
199
+ vec![(
200
+ 3,
201
+ 0x600,
202
+ vec![
203
+ Attr::string(1, "snft_release_binding"),
204
+ Attr::string(3, "foreign"),
205
+ ],
206
+ )],
207
+ )
208
+ .unwrap();
209
+ }
210
+ assert_eq!(fresh.release_transition(invalid), Err(Error::Conflict));
211
+ assert!(fresh.graph().unwrap().is_some());
212
+ assert!(!fresh.inspect().enforced);
213
+ drop(fresh);
214
+ // Disposable fixture cleanup never enters the production API.
215
+ let mut cleanup = Socket::open().unwrap();
216
+ let generation = cleanup.generation().unwrap();
217
+ cleanup
218
+ .batch(
219
+ generation,
220
+ vec![(2, 0, vec![Attr::string(1, "snft_release_binding")])],
221
+ )
222
+ .unwrap();
223
+ }
224
+ }
225
+
226
+ #[test]
227
+ #[ignore = "requires disposable isolated native qualification guest"]
228
+ fn release_transition_retry_never_deletes_a_replacement_for_its_observed_handle() {
229
+ isolated();
230
+ let mut owner = Owner::new(options("release_replaced")).unwrap();
231
+ let command = request(
232
+ &owner,
233
+ Transition {
234
+ previous: None,
235
+ target: target(1),
236
+ },
237
+ );
238
+ let first = owner.reconcile(command.transition.clone()).unwrap();
239
+ // Stop at the exact production boundary after deletion, before its final
240
+ // observation. Retained state must still bind the handle already acquired.
241
+ owner.transition_release = Some(command.clone());
242
+ let graph = owner.graph().unwrap().unwrap();
243
+ owner
244
+ .socket()
245
+ .unwrap()
246
+ .batch(
247
+ graph.generation,
248
+ vec![(
249
+ 2,
250
+ 0,
251
+ vec![Attr::u64(4, wire::handle(&graph.table, 4).unwrap())],
252
+ )],
253
+ )
254
+ .unwrap();
255
+ let mut replacement = Owner::new(options("release_replaced")).unwrap();
256
+ let second = replacement.reconcile(command.transition.clone()).unwrap();
257
+ assert_ne!(first.receipt.table_handle, second.receipt.table_handle);
258
+ drop(replacement);
259
+ assert_eq!(owner.release_transition(command), Err(Error::Conflict));
260
+ let graph = owner.graph().unwrap().unwrap();
261
+ assert_eq!(
262
+ wire::handle(&graph.table, 4).unwrap().to_string(),
263
+ second.receipt.table_handle
264
+ );
265
+ assert_eq!(owner.applied, Some(first));
266
+ assert!(!owner.inspect().enforced);
267
+ drop(owner);
268
+ Owner::new(options("release_replaced"))
269
+ .unwrap()
270
+ .release(second)
271
+ .unwrap();
272
+ }
273
+
274
+ #[test]
275
+ #[ignore = "requires disposable isolated native qualification guest"]
276
+ fn release_transition_recovers_lost_delete_ack_across_actual_native_processes() {
277
+ isolated();
278
+ let namespace = std::fs::File::open("/proc/thread-self/ns/net").unwrap();
279
+ let mut native = NativeOwner::start(&namespace);
280
+ let status = native.call(
281
+ "openOwner",
282
+ serde_json::to_value(options("release_ack")).unwrap(),
283
+ );
284
+ let command = TransitionRelease {
285
+ identity: serde_json::from_value(status["identity"].clone()).unwrap(),
286
+ transition: Transition {
287
+ previous: None,
288
+ target: target(1),
289
+ },
290
+ };
291
+ native.call(
292
+ "reconcilePolicy",
293
+ serde_json::to_value(&command.transition).unwrap(),
294
+ );
295
+ writeln!(
296
+ native.child.stdin.as_mut().unwrap(),
297
+ "{}",
298
+ json!({
299
+ "id":"lost-release", "method":"releasePolicyTransition", "params":command,
300
+ })
301
+ )
302
+ .unwrap();
303
+ let mut observer = Owner::new(options("release_ack")).unwrap();
304
+ let until = Instant::now() + Duration::from_secs(3);
305
+ while observer.graph().unwrap().is_some() {
306
+ assert!(Instant::now() < until);
307
+ std::thread::sleep(Duration::from_millis(5));
308
+ }
309
+ // Never read or retain the release response.
310
+ drop(native);
311
+ let mut fresh = NativeOwner::start(&namespace);
312
+ fresh.call(
313
+ "openOwner",
314
+ serde_json::to_value(options("release_ack")).unwrap(),
315
+ );
316
+ assert_eq!(
317
+ fresh.call(
318
+ "releasePolicyTransition",
319
+ serde_json::to_value(&command).unwrap()
320
+ ),
321
+ json!({"released":true})
322
+ );
323
+ let status = fresh.call("inspectPolicy", json!({}));
324
+ assert_eq!(status["state"], "released");
325
+ assert_eq!(status["enforced"], false);
326
+ assert!(observer.graph().unwrap().is_none());
327
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '2.1.0',
6
+ version: '2.3.0',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }