@push.rocks/smartnftables 1.4.0 → 1.5.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 (41) hide show
  1. package/changelog.md +14 -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.managednftables.d.ts +7 -7
  8. package/dist_ts/classes.managednftables.js +1 -1
  9. package/dist_ts/index.d.ts +1 -0
  10. package/dist_ts/index.js +1 -1
  11. package/dist_ts/managed.egress.types.d.ts +91 -0
  12. package/dist_ts/managed.egress.types.js +2 -0
  13. package/dist_ts/managed.types.d.ts +20 -18
  14. package/package.json +2 -2
  15. package/readme.md +75 -4
  16. package/rust/src/egress.compile.rs +288 -0
  17. package/rust/src/egress.host.rs +103 -0
  18. package/rust/src/egress.router.rs +231 -0
  19. package/rust/src/egress.rs +682 -0
  20. package/rust/src/egress_tests.rs +332 -0
  21. package/rust/src/main.rs +3 -1
  22. package/rust/src/managed.rs +105 -0
  23. package/rust/src/owner.rs +96 -6
  24. package/rust/src/owner_coexistence_tests.rs +77 -0
  25. package/rust/src/owner_egress_identity_tests.rs +155 -0
  26. package/rust/src/owner_egress_tests.rs +153 -0
  27. package/rust/src/owner_egress_traffic_tests.rs +470 -0
  28. package/rust/src/owner_host_traffic_tests.rs +305 -0
  29. package/rust/src/owner_identity_tests.rs +169 -0
  30. package/rust/src/owner_link_tests.rs +119 -0
  31. package/rust/src/owner_packet_fixture.rs +200 -0
  32. package/rust/src/owner_tests.rs +33 -7
  33. package/rust/src/policy.rs +120 -81
  34. package/rust/src/tests.rs +22 -0
  35. package/rust/src/wire.links.rs +268 -0
  36. package/rust/src/wire.rs +3 -64
  37. package/ts/00_commitinfo_data.ts +1 -1
  38. package/ts/classes.managednftables.ts +12 -12
  39. package/ts/index.ts +1 -0
  40. package/ts/managed.egress.types.ts +85 -0
  41. package/ts/managed.types.ts +20 -16
@@ -0,0 +1,332 @@
1
+ use super::{Policy, Scope};
2
+ use serde_json::{json, Value};
3
+
4
+ fn link(index: u32, name: &str, kind: &str, address: &str) -> Value {
5
+ json!({"interfaceIndex":index,"interfaceName":name,"interfaceKind":kind,
6
+ "macAddress":if kind == "tun" { Value::Null } else { json!(format!("02:00:00:00:00:{index:02x}")) },
7
+ "interfaceLinkIndex":index+100,"requiredIpv4Addresses":[address]})
8
+ }
9
+ fn allocation() -> Value {
10
+ json!({"lease":{"id":"lease-a","generation":1,"digest":format!("sha256:{}","a".repeat(64))},
11
+ "transitSourceAddress":"10.240.0.2",
12
+ "sourcePortRanges":[{"protocol":"tcp","first":10000,"last":10999},{"protocol":"udp","first":10000,"last":10999}]})
13
+ }
14
+ fn protection() -> Value {
15
+ json!({"authorityDigest":format!("sha256:{}","b".repeat(64)),
16
+ "prefixes":["10.0.0.0/8","192.0.2.0/24","203.0.113.10/32"],
17
+ "platformEndpoints":[{"id":"dns","address":"192.0.2.1","protocol":"udp","port":53},
18
+ {"id":"relay","address":"203.0.113.10","protocol":"tcp","port":443}]})
19
+ }
20
+ pub(crate) fn router() -> Value {
21
+ let mut generation = allocation();
22
+ generation["conntrackZone"] = json!(1);
23
+ generation["conntrackLabel"] = json!("a".repeat(32));
24
+ generation["grants"] = json!([
25
+ {"sourceEndpoint":"work","sourcePrefix":"10.241.0.2/32","destination":{"kind":"public","prefix":"0.0.0.0/0"},
26
+ "protocol":"tcp","sourcePort":null,"destinationPort":443,"sourcePortRange":{"first":10000,"last":10999}},
27
+ {"sourceEndpoint":null,"sourcePrefix":"10.240.0.2/32","destination":{"kind":"platform","endpointId":"dns"},
28
+ "protocol":"udp","sourcePort":null,"destinationPort":53,"sourcePortRange":{"first":10000,"last":10999}},
29
+ {"sourceEndpoint":null,"sourcePrefix":"10.240.0.2/32","destination":{"kind":"platform","endpointId":"relay"},
30
+ "protocol":"tcp","sourcePort":null,"destinationPort":443,"sourcePortRange":{"first":10000,"last":10999}}
31
+ ]);
32
+ json!({"schemaVersion":2,"revision":1,"scope":{"kind":"routerEgress",
33
+ "endpoints":[
34
+ {"id":"work","interfaceIndex":2,"interfaceName":"work","interfaceKind":"veth","sourcePrefixes":["10.241.0.2/32"]},
35
+ {"id":"vpn","interfaceIndex":3,"interfaceName":"vpn","interfaceKind":"tun","sourcePrefixes":["10.242.0.0/24"]}],
36
+ "rules":[
37
+ {"sourceEndpoint":"work","destinationEndpoint":"vpn","sourcePrefix":"10.241.0.2/32","destinationPrefix":"10.242.0.2/32","protocol":"udp","sourcePort":null,"destinationPort":53},
38
+ {"sourceEndpoint":"work","destinationEndpoint":null,"sourcePrefix":"10.241.0.2/32","destinationPrefix":"10.241.0.1/32","protocol":"udp","sourcePort":null,"destinationPort":53},
39
+ {"sourceEndpoint":null,"destinationEndpoint":"work","sourcePrefix":"10.241.0.1/32","destinationPrefix":"10.241.0.2/32","protocol":"udp","sourcePort":53,"destinationPort":null}],
40
+ "links":[link(2,"work","veth","10.241.0.1"),link(3,"vpn","tun","10.242.0.1")],
41
+ "handoff":link(4,"handoff","veth","10.240.0.2"),"protection":protection(),"generations":[generation]}})
42
+ }
43
+ pub(crate) fn host() -> Value {
44
+ json!({"schemaVersion":2,"revision":1,"scope":{"kind":"hostTransit","protection":protection(),
45
+ "handoffs":[{"link":link(4,"handoff","veth","10.240.0.1"),"allocations":[allocation()]}],
46
+ "uplink":link(2,"ens18","ethernet","192.0.2.2"),"snatAddress":"192.0.2.2"}})
47
+ }
48
+ fn normalized(value: Value) -> super::Result<Policy> {
49
+ serde_json::from_value::<Policy>(value)
50
+ .map_err(|_| crate::Error::Invalid)?
51
+ .normalize()
52
+ }
53
+ fn changed(mut value: Value, path: &str, replacement: Value) -> Value {
54
+ *value.pointer_mut(path).unwrap() = replacement;
55
+ value
56
+ }
57
+
58
+ #[test]
59
+ fn combined_router_preserves_private_tun_dns_and_explicit_local_egress() {
60
+ let expected = normalized(router()).unwrap();
61
+ let mut reordered = router();
62
+ for path in [
63
+ "/scope/endpoints",
64
+ "/scope/rules",
65
+ "/scope/links",
66
+ "/scope/protection/prefixes",
67
+ "/scope/protection/platformEndpoints",
68
+ "/scope/generations/0/sourcePortRanges",
69
+ "/scope/generations/0/grants",
70
+ ] {
71
+ reordered
72
+ .pointer_mut(path)
73
+ .unwrap()
74
+ .as_array_mut()
75
+ .unwrap()
76
+ .reverse();
77
+ }
78
+ assert_eq!(normalized(reordered).unwrap(), expected);
79
+ let denied = normalized(changed(router(), "/scope/generations", json!([]))).unwrap();
80
+ let Scope::RouterEgress(denied) = denied.scope else {
81
+ panic!()
82
+ };
83
+ assert_eq!(denied.endpoints.len(), 2);
84
+ assert_eq!(denied.rules.len(), 3);
85
+ assert!(denied.generations.is_empty());
86
+ assert!(normalized(host()).is_ok());
87
+ }
88
+
89
+ #[test]
90
+ fn v2_rejects_unknown_fields_missing_nulls_and_wrong_scope_shapes() {
91
+ let mut unknown = router();
92
+ unknown["commands"] = json!([]);
93
+ let mut no_null = router();
94
+ no_null["scope"]["generations"][0]["grants"][0]
95
+ .as_object_mut()
96
+ .unwrap()
97
+ .remove("sourcePort");
98
+ let mut mixed = host();
99
+ mixed["scope"]["generations"] = json!([]);
100
+ for invalid in [
101
+ unknown,
102
+ no_null,
103
+ mixed,
104
+ changed(router(), "/schemaVersion", json!(1)),
105
+ changed(router(), "/revision", json!(0)),
106
+ changed(router(), "/revision", json!(9007199254740992_u64)),
107
+ changed(router(), "/scope/kind", json!("egress")),
108
+ ] {
109
+ assert!(normalized(invalid).is_err());
110
+ }
111
+ let duplicate = serde_json::to_string(&router()).unwrap().replace(
112
+ "\"schemaVersion\":2",
113
+ "\"schemaVersion\":2,\"schemaVersion\":2",
114
+ );
115
+ assert!(serde_json::from_str::<Policy>(&duplicate).is_err());
116
+ }
117
+
118
+ #[test]
119
+ fn v2_binds_exact_local_links_addresses_and_complete_protection() {
120
+ for (path, replacement) in [
121
+ ("/scope/handoff/interfaceKind", json!("ethernet")),
122
+ ("/scope/handoff/interfaceIndex", json!(2)),
123
+ ("/scope/handoff/macAddress", json!("03:00:00:00:00:04")),
124
+ ("/scope/handoff/macAddress", json!("00:00:00:00:00:00")),
125
+ ("/scope/handoff/interfaceLinkIndex", json!(0)),
126
+ ("/scope/links/0/interfaceName", json!("foreign")),
127
+ ("/scope/links/1/macAddress", json!("02:00:00:00:00:03")),
128
+ (
129
+ "/scope/links/0/requiredIpv4Addresses",
130
+ json!(["10.241.0.1", "10.241.0.1"]),
131
+ ),
132
+ ("/scope/protection/prefixes", json!(["10.0.0.0/8"])),
133
+ (
134
+ "/scope/protection/prefixes",
135
+ json!([
136
+ "10.0.0.0/8",
137
+ "10.240.0.0/16",
138
+ "192.0.2.0/24",
139
+ "203.0.113.10/32"
140
+ ]),
141
+ ),
142
+ (
143
+ "/scope/protection/platformEndpoints/0/address",
144
+ json!("10.241.0.1"),
145
+ ),
146
+ (
147
+ "/scope/protection/platformEndpoints/0/address",
148
+ json!("10.241.0.2"),
149
+ ),
150
+ ("/scope/protection/authorityDigest", json!("sha256:bad")),
151
+ ] {
152
+ assert!(
153
+ normalized(changed(router(), path, replacement)).is_err(),
154
+ "{path}"
155
+ );
156
+ }
157
+ for (path, replacement) in [
158
+ ("/scope/snatAddress", json!("192.0.2.4")),
159
+ ("/scope/uplink/interfaceKind", json!("tun")),
160
+ (
161
+ "/scope/handoffs/0/allocations/0/transitSourceAddress",
162
+ json!("10.240.0.1"),
163
+ ),
164
+ (
165
+ "/scope/protection/platformEndpoints/0/address",
166
+ json!("192.0.2.2"),
167
+ ),
168
+ ] {
169
+ assert!(
170
+ normalized(changed(host(), path, replacement)).is_err(),
171
+ "{path}"
172
+ );
173
+ }
174
+ }
175
+
176
+ #[test]
177
+ fn v2_rejects_unleased_ports_unbound_sources_and_ambiguous_platform_grants() {
178
+ for (path, replacement) in [
179
+ ("/scope/generations/0/lease/generation", json!(0)),
180
+ ("/scope/generations/0/conntrackZone", json!(0)),
181
+ ("/scope/generations/0/conntrackZone", json!(65536)),
182
+ ("/scope/generations/0/conntrackLabel", json!("0".repeat(32))),
183
+ (
184
+ "/scope/generations/0/transitSourceAddress",
185
+ json!("10.240.0.3"),
186
+ ),
187
+ ("/scope/generations/0/sourcePortRanges/0/first", json!(0)),
188
+ ("/scope/generations/0/sourcePortRanges/0/last", json!(9999)),
189
+ (
190
+ "/scope/generations/0/grants/0/sourcePortRange/last",
191
+ json!(11000),
192
+ ),
193
+ ("/scope/generations/0/grants/0/sourceEndpoint", json!("vpn")),
194
+ (
195
+ "/scope/generations/0/grants/0/sourcePrefix",
196
+ json!("10.241.0.0/24"),
197
+ ),
198
+ ("/scope/generations/0/grants/0/sourcePort", json!(0)),
199
+ (
200
+ "/scope/generations/0/grants/0/destination/prefix",
201
+ json!("10.242.0.0/24"),
202
+ ),
203
+ (
204
+ "/scope/generations/0/grants/1/sourcePrefix",
205
+ json!("10.240.0.0/24"),
206
+ ),
207
+ ("/scope/generations/0/grants/1/destinationPort", Value::Null),
208
+ ("/scope/generations/0/grants/1/protocol", json!("tcp")),
209
+ ] {
210
+ assert!(
211
+ normalized(changed(router(), path, replacement)).is_err(),
212
+ "{path}"
213
+ );
214
+ }
215
+ }
216
+
217
+ #[test]
218
+ fn v2_classification_and_return_ranges_are_disjoint_between_active_generations() {
219
+ let mut value = router();
220
+ value["scope"]["generations"][0]["grants"]
221
+ .as_array_mut()
222
+ .unwrap()
223
+ .truncate(1);
224
+ let mut next = value["scope"]["generations"][0].clone();
225
+ next["lease"]["id"] = json!("lease-b");
226
+ next["conntrackZone"] = json!(2);
227
+ next["conntrackLabel"] = json!("b".repeat(32));
228
+ next["sourcePortRanges"] = json!([{"protocol":"tcp","first":11000,"last":11999}]);
229
+ next["grants"][0]["sourcePortRange"] = json!({"first":11000,"last":11999});
230
+ value["scope"]["generations"]
231
+ .as_array_mut()
232
+ .unwrap()
233
+ .push(next);
234
+ assert!(normalized(value.clone()).is_err());
235
+ value["scope"]["generations"][0]["grants"][0]["sourcePort"] = json!(1000);
236
+ value["scope"]["generations"][1]["grants"][0]["sourcePort"] = json!(2000);
237
+ assert!(normalized(value.clone()).is_ok());
238
+ for (path, replacement) in [
239
+ ("/scope/generations/1/conntrackZone", json!(1)),
240
+ ("/scope/generations/1/conntrackLabel", json!("a".repeat(32))),
241
+ ("/scope/generations/1/lease/id", json!("lease-a")),
242
+ ] {
243
+ assert!(normalized(changed(value.clone(), path, replacement)).is_err());
244
+ }
245
+ value["scope"]["generations"][1]["sourcePortRanges"][0] =
246
+ json!({"protocol":"tcp","first":10500,"last":11500});
247
+ value["scope"]["generations"][1]["grants"][0]["sourcePortRange"] =
248
+ json!({"first":10500,"last":11500});
249
+ assert!(normalized(value).is_err());
250
+ }
251
+
252
+ #[test]
253
+ fn host_never_assigns_the_same_transit_source_to_different_active_handoff_links() {
254
+ let mut value = host();
255
+ let mut other = value["scope"]["handoffs"][0].clone();
256
+ other["link"] = link(5, "handoff-b", "veth", "10.240.0.5");
257
+ other["allocations"][0]["lease"]["id"] = json!("lease-b");
258
+ other["allocations"][0]["sourcePortRanges"] =
259
+ json!([{"protocol":"udp","first":12000,"last":12999}]);
260
+ value["scope"]["handoffs"]
261
+ .as_array_mut()
262
+ .unwrap()
263
+ .push(other);
264
+ assert!(normalized(value.clone()).is_err());
265
+ value["scope"]["handoffs"][1]["allocations"][0]["transitSourceAddress"] = json!("10.240.0.6");
266
+ assert!(normalized(value).is_ok());
267
+ }
268
+
269
+ #[test]
270
+ fn prepared_v2_compiles_both_scopes_with_bounded_and_identifiable_graphs() {
271
+ for fixture in [router(), host()] {
272
+ let prepared = serde_json::from_value::<Policy>(fixture)
273
+ .unwrap()
274
+ .prepare()
275
+ .unwrap();
276
+ prepared.validate().unwrap();
277
+ let program = prepared.program("snft_v2_fixture").unwrap();
278
+ assert!(program.len() <= 768);
279
+ assert!(
280
+ program
281
+ .iter()
282
+ .map(|(_, attributes)| crate::wire::encode_attrs(attributes).len() + 20)
283
+ .sum::<usize>()
284
+ <= 100_000
285
+ );
286
+ assert!(program.iter().all(|(kind, _)| [3, 6].contains(kind)));
287
+ let mut changed = prepared.clone();
288
+ changed.policy.revision += 1;
289
+ assert!(changed.validate().is_err());
290
+ assert_ne!(changed.policy.prepare().unwrap().digest, prepared.digest);
291
+ println!("V2_GRAPH operations={}", program.len());
292
+ }
293
+ }
294
+
295
+ #[test]
296
+ fn v2_compiled_graph_limit_rejects_bounded_but_excessive_cross_products() {
297
+ let mut value = router();
298
+ let mut grants = Vec::new();
299
+ for port in 1000..1032 {
300
+ let mut grant = value["scope"]["generations"][0]["grants"][0].clone();
301
+ grant["destinationPort"] = json!(port);
302
+ grants.push(grant);
303
+ }
304
+ value["scope"]["generations"][0]["grants"] = json!(grants);
305
+ let policy = normalized(value).unwrap();
306
+ assert!(policy.prepare().is_err());
307
+ }
308
+
309
+ #[test]
310
+ fn managed_union_keeps_strict_duplicate_and_unknown_field_rejection() {
311
+ for value in [router(), host()] {
312
+ let text = serde_json::to_string(&value).unwrap();
313
+ assert!(serde_json::from_str::<crate::managed::Policy>(&text).is_ok());
314
+ for invalid in [
315
+ text.replace(
316
+ "\"schemaVersion\":2",
317
+ "\"schemaVersion\":2,\"schemaVersion\":2",
318
+ ),
319
+ text.replace("\"revision\":1", "\"revision\":1,\"revision\":2"),
320
+ text.replace(
321
+ "\"interfaceIndex\":2",
322
+ "\"interfaceIndex\":2,\"interfaceIndex\":2",
323
+ ),
324
+ ] {
325
+ assert!(serde_json::from_str::<crate::managed::Policy>(&invalid).is_err());
326
+ }
327
+ }
328
+ assert!(serde_json::from_str::<crate::managed::Policy>(
329
+ r#"{"schemaVersion":1,"schemaVersion":1,"revision":1,"endpoints":[],"rules":[]}"#
330
+ )
331
+ .is_err());
332
+ }
package/rust/src/main.rs CHANGED
@@ -1,3 +1,5 @@
1
+ mod egress;
2
+ mod managed;
1
3
  mod namespace;
2
4
  mod owner;
3
5
  mod policy;
@@ -59,7 +61,7 @@ fn encode(value: impl serde::Serialize) -> Result<serde_json::Value> {
59
61
  }
60
62
  fn dispatch(owner: &mut Option<owner::Owner>, request: Request) -> Result<serde_json::Value> {
61
63
  match request.method.as_str() {
62
- "preparePolicy" => encode(decode::<policy::Policy>(request.params)?.prepare()?),
64
+ "preparePolicy" => encode(decode::<managed::Policy>(request.params)?.prepare()?),
63
65
  "openOwner" => {
64
66
  if owner.is_some() {
65
67
  return Err(Error::Conflict);
@@ -0,0 +1,105 @@
1
+ use crate::{egress, policy, wire, Result};
2
+ use serde::{Deserialize, Serialize};
3
+
4
+ /// The v1 object remains byte-for-byte unchanged inside this input union.
5
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
6
+ #[serde(untagged)]
7
+ pub enum Policy {
8
+ Private(policy::Policy),
9
+ Egress(egress::Policy),
10
+ }
11
+
12
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
13
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
14
+ pub struct Prepared {
15
+ pub policy: Policy,
16
+ pub digest: String,
17
+ }
18
+
19
+ impl Policy {
20
+ pub fn revision(&self) -> u64 {
21
+ match self {
22
+ Self::Private(value) => value.revision,
23
+ Self::Egress(value) => value.revision,
24
+ }
25
+ }
26
+ pub fn kind(&self) -> &'static str {
27
+ match self {
28
+ Self::Private(_) => "privateV1",
29
+ Self::Egress(egress::Policy {
30
+ scope: egress::Scope::RouterEgress(_),
31
+ ..
32
+ }) => "routerEgressV2",
33
+ Self::Egress(egress::Policy {
34
+ scope: egress::Scope::HostTransit(_),
35
+ ..
36
+ }) => "hostTransitV2",
37
+ }
38
+ }
39
+ pub fn prepare(self) -> Result<Prepared> {
40
+ match self {
41
+ Self::Private(value) => Ok(value.prepare()?.into()),
42
+ Self::Egress(value) => Ok(value.prepare()?.into()),
43
+ }
44
+ }
45
+ }
46
+ impl From<policy::Prepared> for Prepared {
47
+ fn from(value: policy::Prepared) -> Self {
48
+ Self {
49
+ policy: Policy::Private(value.policy),
50
+ digest: value.digest,
51
+ }
52
+ }
53
+ }
54
+ impl From<egress::Prepared> for Prepared {
55
+ fn from(value: egress::Prepared) -> Self {
56
+ Self {
57
+ policy: Policy::Egress(value.policy),
58
+ digest: value.digest,
59
+ }
60
+ }
61
+ }
62
+ impl Prepared {
63
+ pub fn validate(&self) -> Result<()> {
64
+ match &self.policy {
65
+ Policy::Private(policy) => policy::Prepared {
66
+ policy: policy.clone(),
67
+ digest: self.digest.clone(),
68
+ }
69
+ .validate(),
70
+ Policy::Egress(policy) => egress::Prepared {
71
+ policy: policy.clone(),
72
+ digest: self.digest.clone(),
73
+ }
74
+ .validate(),
75
+ }
76
+ }
77
+ pub fn program(&self, table: &str) -> Result<Vec<(u16, Vec<wire::Attr>)>> {
78
+ match &self.policy {
79
+ Policy::Private(policy) => policy::Prepared {
80
+ policy: policy.clone(),
81
+ digest: self.digest.clone(),
82
+ }
83
+ .program(table),
84
+ Policy::Egress(policy) => egress::Prepared {
85
+ policy: policy.clone(),
86
+ digest: self.digest.clone(),
87
+ }
88
+ .program(table),
89
+ }
90
+ }
91
+ pub fn validate_interfaces(&self) -> Result<()> {
92
+ match &self.policy {
93
+ Policy::Private(policy) => policy::Prepared {
94
+ policy: policy.clone(),
95
+ digest: self.digest.clone(),
96
+ }
97
+ .validate_interfaces(),
98
+ Policy::Egress(policy) => egress::Prepared {
99
+ policy: policy.clone(),
100
+ digest: self.digest.clone(),
101
+ }
102
+ .validate_interfaces(),
103
+ }
104
+ }
105
+ }
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};
@@ -171,7 +172,15 @@ impl Owner {
171
172
  Err(error) => return Err(error),
172
173
  _ => return Err(Error::Protocol),
173
174
  };
174
- let chains = socket.query(4, vec![Attr::string(1, &table_name)], true)?;
175
+ // Linux's NFT_MSG_GETCHAIN dump is family-wide: it ignores
176
+ // NFTA_CHAIN_TABLE. Select exact table identity before examining
177
+ // chain contents; another owner's chains are not our graph.
178
+ let mut chains = Vec::new();
179
+ for chain in socket.query(4, vec![Attr::string(1, &table_name)], true)? {
180
+ if wire::text(&chain, 1)? == table_name {
181
+ chains.push(chain);
182
+ }
183
+ }
175
184
  let rules = socket.query(7, vec![Attr::string(1, &table_name)], true)?;
176
185
  // The managed compiler never creates any of these. Foreign objects reject.
177
186
  for operation in [10, 19, 23] {
@@ -222,7 +231,7 @@ impl Owner {
222
231
  if handle == 0
223
232
  || handle.to_string() != expected.receipt.table_handle
224
233
  || expected.receipt.identity != self.identity
225
- || expected.receipt.revision != expected.prepared.policy.revision
234
+ || expected.receipt.revision != expected.prepared.policy.revision()
226
235
  || expected.receipt.digest != expected.prepared.digest
227
236
  {
228
237
  return Err(Error::Conflict);
@@ -316,7 +325,7 @@ impl Owner {
316
325
  receipt: Receipt {
317
326
  identity: self.identity.clone(),
318
327
  table_handle: wire::handle(&graph.table, 4)?.to_string(),
319
- revision: prepared.policy.revision,
328
+ revision: prepared.policy.revision(),
320
329
  digest: prepared.digest.clone(),
321
330
  },
322
331
  prepared,
@@ -334,7 +343,9 @@ impl Owner {
334
343
  }
335
344
  if let Some(previous) = &transition.previous {
336
345
  self.validate_receipt(previous)?;
337
- if transition.target.policy.revision <= previous.prepared.policy.revision {
346
+ if transition.target.policy.kind() != previous.prepared.policy.kind()
347
+ || transition.target.policy.revision() <= previous.prepared.policy.revision()
348
+ {
338
349
  return Err(Error::Conflict);
339
350
  }
340
351
  }
@@ -568,11 +579,13 @@ fn rule_identity(attributes: &[Attr]) -> Result<(String, Vec<u8>)> {
568
579
  }
569
580
  let name = wire::text(&fields, 1)?;
570
581
  let mut data = wire::attrs(&wire::one(&fields, 2)?.value)?;
582
+ validate_egress_expression(&name, &data)?;
571
583
  let nested: &[u16] = match name.as_str() {
572
584
  "immediate" => &[2],
573
585
  "cmp" => &[3],
574
586
  "bitwise" => &[4, 5],
575
- "meta" | "payload" => &[],
587
+ "range" => &[3, 4],
588
+ "meta" | "payload" | "ct" | "nat" => &[],
576
589
  _ => return Err(Error::Conflict),
577
590
  };
578
591
  for attribute in &mut data {
@@ -599,6 +612,83 @@ fn rule_identity(attributes: &[Attr]) -> Result<(String, Vec<u8>)> {
599
612
  Ok((chain, encoded))
600
613
  }
601
614
 
615
+ /// These expressions are compiler-owned. Preserve all operands in graph identity;
616
+ /// neither unknown fields nor default repair may hide a changed kernel rule.
617
+ fn validate_egress_expression(name: &str, data: &[Attr]) -> Result<()> {
618
+ let allowed: &[u16] = match name {
619
+ "ct" | "range" => &[1, 2, 3, 4],
620
+ "nat" => &[1, 2, 3, 4, 5, 6, 7],
621
+ _ => return Ok(()),
622
+ };
623
+ for attribute in data {
624
+ if !allowed.contains(&attribute.id()) {
625
+ return Err(Error::Conflict);
626
+ }
627
+ wire::one(data, attribute.id())?; // Duplicate fields are never canonical identity.
628
+ }
629
+ match name {
630
+ "ct" => {
631
+ let read = data.iter().any(|item| item.id() == 1);
632
+ let write = data.iter().any(|item| item.id() == 4);
633
+ if read == write {
634
+ return Err(Error::Conflict);
635
+ }
636
+ wire::number(data, if read { 1 } else { 4 })?;
637
+ wire::number(data, 2)?;
638
+ if data.iter().any(|item| item.id() == 3)
639
+ && ![vec![0], vec![1]].contains(&wire::one(data, 3)?.value)
640
+ {
641
+ return Err(Error::Conflict);
642
+ }
643
+ }
644
+ "nat" => {
645
+ // Emit min/max registers and derived flags explicitly, just as Linux
646
+ // dumps them. This surface only compiles address-bearing IPv4 NAT.
647
+ for id in [1, 2, 3, 4, 7] {
648
+ wire::number(data, id)?;
649
+ }
650
+ if wire::number(data, 1)? > 1 || wire::number(data, 2)? != 2 {
651
+ return Err(Error::Conflict);
652
+ }
653
+ let first_port = data.iter().any(|item| item.id() == 5);
654
+ let last_port = data.iter().any(|item| item.id() == 6);
655
+ if first_port != last_port {
656
+ return Err(Error::Conflict);
657
+ }
658
+ if first_port {
659
+ wire::number(data, 5)?;
660
+ wire::number(data, 6)?;
661
+ }
662
+ }
663
+ "range" => {
664
+ wire::number(data, 1)?;
665
+ if wire::number(data, 2)? > 1 || data.len() != 4 {
666
+ return Err(Error::Conflict);
667
+ }
668
+ let mut length = None;
669
+ for id in [3, 4] {
670
+ let value = wire::attrs(&wire::one(data, id)?.value)?;
671
+ if value.len() != 1 {
672
+ return Err(Error::Conflict);
673
+ }
674
+ let bytes = &wire::one(&value, 1)?.value;
675
+ if !(1..=16).contains(&bytes.len())
676
+ || length.is_some_and(|size| size != bytes.len())
677
+ {
678
+ return Err(Error::Conflict);
679
+ }
680
+ length = Some(bytes.len());
681
+ }
682
+ }
683
+ _ => unreachable!(),
684
+ }
685
+ Ok(())
686
+ }
687
+
688
+ #[cfg(test)]
689
+ #[path = "owner_identity_tests.rs"]
690
+ mod identity_tests;
691
+
602
692
  #[cfg(test)]
603
693
  #[path = "owner_tests.rs"]
604
694
  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
+ }