@push.rocks/smartnftables 2.5.2 → 3.0.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.
- package/changelog.md +31 -0
- package/dist_rust/smartnftables_linux_amd64_musl +0 -0
- package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
- package/dist_rust/smartnftables_linux_arm64_musl +0 -0
- package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/managed.egress.types.d.ts +69 -3
- package/package.json +6 -6
- package/readme.md +342 -61
- package/rust/src/egress.compile.rs +157 -76
- package/rust/src/egress.host.rs +104 -44
- package/rust/src/egress.hostgrant.rs +14 -14
- package/rust/src/egress.keys.rs +305 -0
- package/rust/src/egress.leased.rs +399 -0
- package/rust/src/egress.poolguard.rs +45 -0
- package/rust/src/egress.private.rs +197 -0
- package/rust/src/egress.published.rs +370 -0
- package/rust/src/egress.router.rs +64 -295
- package/rust/src/egress.rs +248 -17
- package/rust/src/egress.workloadgrant.rs +88 -0
- package/rust/src/egress_hostgrant_tests.rs +196 -47
- package/rust/src/egress_localplatform_tests.rs +204 -0
- package/rust/src/egress_localport_tests.rs +266 -0
- package/rust/src/egress_publishedrange_tests.rs +812 -0
- package/rust/src/egress_routerequivalence_tests.rs +448 -0
- package/rust/src/egress_tests.rs +132 -230
- package/rust/src/egress_workloadgrant_tests.rs +313 -0
- package/rust/src/owner.rs +77 -15
- package/rust/src/owner_host_traffic_tests.rs +4 -0
- package/rust/src/owner_hostgrant_traffic_tests.rs +9 -4
- package/rust/src/owner_identity_tests.rs +228 -80
- package/rust/src/owner_localplatform_tests.rs +274 -0
- package/rust/src/owner_localport_tests.rs +241 -0
- package/rust/src/owner_publishedrange_traffic_tests.rs +306 -0
- package/rust/src/owner_router_traffic_tests.rs +2 -0
- package/rust/src/owner_scale_traffic_tests.rs +383 -0
- package/rust/src/owner_tests.rs +15 -0
- package/rust/src/owner_workloadgrant_tests.rs +278 -0
- package/rust/src/policy.rs +29 -25
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/managed.egress.types.ts +71 -3
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
//! Published port ranges and symmetric publications on both hops: a range is one
|
|
2
|
+
//! interval match that keeps every port, and a symmetric publication lets its
|
|
3
|
+
//! workload open flows from its published ports toward unprotected space only.
|
|
4
|
+
use super::hostgrant_tests::{
|
|
5
|
+
bytes, decide, normalized, prepared, Flow, Translation, ACK, ESTABLISHED,
|
|
6
|
+
};
|
|
7
|
+
use super::tests::{published_host, published_port, published_router, router_published_port};
|
|
8
|
+
use super::Prepared;
|
|
9
|
+
use serde_json::{json, Value};
|
|
10
|
+
|
|
11
|
+
const NEW: u32 = 8;
|
|
12
|
+
const SYN: u8 = 0x02;
|
|
13
|
+
const CLIENT: [u8; 4] = [198, 51, 100, 7];
|
|
14
|
+
const TRANSIT: [u8; 4] = [10, 240, 0, 2];
|
|
15
|
+
const UPLINK: [u8; 4] = [192, 0, 2, 2];
|
|
16
|
+
const WORK: [u8; 4] = [10, 241, 0, 2];
|
|
17
|
+
const HANDOFF: Option<(u32, &str)> = Some((4, "handoff"));
|
|
18
|
+
const ENS18: Option<(u32, &str)> = Some((2, "ens18"));
|
|
19
|
+
const WORK_LINK: Option<(u32, &str)> = Some((2, "work"));
|
|
20
|
+
/// The fixture ranges: first, middle and last port, then the ports just outside.
|
|
21
|
+
const INSIDE: [u16; 3] = [20000, 20100, 20200];
|
|
22
|
+
const OUTSIDE: [u16; 2] = [19999, 20201];
|
|
23
|
+
|
|
24
|
+
/// A published uplink range `first..=end` that keeps every port.
|
|
25
|
+
fn host_range(protocol: &str, first: u16, end: u16) -> Value {
|
|
26
|
+
let mut value = published_port(protocol, first, first);
|
|
27
|
+
value["hostPortEnd"] = json!(end);
|
|
28
|
+
value
|
|
29
|
+
}
|
|
30
|
+
/// A published transit range `first..=end` that keeps every port.
|
|
31
|
+
fn router_range(protocol: &str, first: u16, end: u16) -> Value {
|
|
32
|
+
let mut value = router_published_port(protocol, first, first);
|
|
33
|
+
value["transitPortEnd"] = json!(end);
|
|
34
|
+
value
|
|
35
|
+
}
|
|
36
|
+
fn symmetric(mut value: Value) -> Value {
|
|
37
|
+
value["symmetric"] = json!(true);
|
|
38
|
+
value
|
|
39
|
+
}
|
|
40
|
+
fn operations(prepared: &Prepared) -> usize {
|
|
41
|
+
prepared.program("snft_published_range").unwrap().len()
|
|
42
|
+
}
|
|
43
|
+
use super::tests::cost;
|
|
44
|
+
/// One packet of a default-zone flow; `original` is the tracked original tuple.
|
|
45
|
+
fn packet(
|
|
46
|
+
input: Option<(u32, &'static str)>,
|
|
47
|
+
output: Option<(u32, &'static str)>,
|
|
48
|
+
protocol: u8,
|
|
49
|
+
(source, source_port): ([u8; 4], u16),
|
|
50
|
+
(destination, destination_port): ([u8; 4], u16),
|
|
51
|
+
original: ([u8; 4], [u8; 4], u16, u16),
|
|
52
|
+
) -> Flow {
|
|
53
|
+
Flow {
|
|
54
|
+
input,
|
|
55
|
+
output,
|
|
56
|
+
protocol,
|
|
57
|
+
source,
|
|
58
|
+
destination,
|
|
59
|
+
source_port,
|
|
60
|
+
destination_port,
|
|
61
|
+
tcp_flags: SYN,
|
|
62
|
+
state: NEW,
|
|
63
|
+
reply: false,
|
|
64
|
+
zone: 0,
|
|
65
|
+
original,
|
|
66
|
+
uid: None,
|
|
67
|
+
label: [0; 16],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/// The answer to an original-direction packet on the reverse links.
|
|
71
|
+
fn reply(flow: Flow) -> Flow {
|
|
72
|
+
Flow {
|
|
73
|
+
input: flow.output,
|
|
74
|
+
output: flow.input,
|
|
75
|
+
source: flow.destination,
|
|
76
|
+
destination: flow.source,
|
|
77
|
+
source_port: flow.destination_port,
|
|
78
|
+
destination_port: flow.source_port,
|
|
79
|
+
tcp_flags: ACK,
|
|
80
|
+
state: ESTABLISHED,
|
|
81
|
+
reply: true,
|
|
82
|
+
..flow
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
fn snat(address: [u8; 4], first: u16, last: u16) -> Option<Translation> {
|
|
86
|
+
Some(Translation {
|
|
87
|
+
kind: 0,
|
|
88
|
+
address,
|
|
89
|
+
ports: Some((first, last)),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn absent_range_and_symmetric_fields_keep_the_exact_policy_digest_and_bytes() {
|
|
95
|
+
// The single-port goldens of the host and router publication tests were
|
|
96
|
+
// frozen before these fields existed and still hold; here the explicit
|
|
97
|
+
// `symmetric: false` is the same canonical policy as the absent field.
|
|
98
|
+
for (fixture, entry) in [
|
|
99
|
+
(
|
|
100
|
+
published_host as fn(Value) -> Value,
|
|
101
|
+
published_port("udp", 8125, 8125),
|
|
102
|
+
),
|
|
103
|
+
(published_router, router_published_port("udp", 8125, 9125)),
|
|
104
|
+
] {
|
|
105
|
+
let baseline = prepared(fixture(json!([entry.clone()])));
|
|
106
|
+
let mut explicit = entry.clone();
|
|
107
|
+
explicit["symmetric"] = json!(false);
|
|
108
|
+
let same = prepared(fixture(json!([explicit])));
|
|
109
|
+
assert_eq!(same, baseline);
|
|
110
|
+
assert_eq!(bytes(&same), bytes(&baseline));
|
|
111
|
+
let serialized = serde_json::to_string(&baseline.policy).unwrap();
|
|
112
|
+
for field in ["symmetric", "hostPortEnd", "transitPortEnd"] {
|
|
113
|
+
assert!(!serialized.contains(field), "{field}");
|
|
114
|
+
}
|
|
115
|
+
let changed = prepared(fixture(json!([symmetric(entry)])));
|
|
116
|
+
assert_ne!(changed.digest, baseline.digest);
|
|
117
|
+
assert!(serde_json::to_string(&changed.policy)
|
|
118
|
+
.unwrap()
|
|
119
|
+
.contains("\"symmetric\":true"));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn host_range_translates_every_port_by_address_and_costs_one_port() {
|
|
125
|
+
let single = prepared(published_host(json!([published_port("udp", 20000, 20000)])));
|
|
126
|
+
let ranged = prepared(published_host(json!([host_range("udp", 20000, 20200)])));
|
|
127
|
+
assert_eq!(operations(&ranged), operations(&single));
|
|
128
|
+
assert!(serde_json::to_string(&ranged.policy)
|
|
129
|
+
.unwrap()
|
|
130
|
+
.contains("\"hostPortEnd\":20200"));
|
|
131
|
+
for port in INSIDE {
|
|
132
|
+
let arrival = packet(
|
|
133
|
+
ENS18,
|
|
134
|
+
None,
|
|
135
|
+
17,
|
|
136
|
+
(CLIENT, 40000),
|
|
137
|
+
(UPLINK, port),
|
|
138
|
+
(CLIENT, UPLINK, 40000, port),
|
|
139
|
+
);
|
|
140
|
+
// Address-only destination NAT keeps the port, and the client address.
|
|
141
|
+
assert_eq!(
|
|
142
|
+
decide(&ranged, "pre", &arrival).translation,
|
|
143
|
+
Some(Translation {
|
|
144
|
+
kind: 1,
|
|
145
|
+
address: TRANSIT,
|
|
146
|
+
ports: None,
|
|
147
|
+
}),
|
|
148
|
+
"{port}"
|
|
149
|
+
);
|
|
150
|
+
let translated = Flow {
|
|
151
|
+
output: HANDOFF,
|
|
152
|
+
destination: TRANSIT,
|
|
153
|
+
..arrival
|
|
154
|
+
};
|
|
155
|
+
assert_eq!(decide(&ranged, "forward", &translated).verdict, 1, "{port}");
|
|
156
|
+
assert_eq!(
|
|
157
|
+
decide(&ranged, "forward", &reply(translated)).verdict,
|
|
158
|
+
1,
|
|
159
|
+
"{port}"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for port in OUTSIDE {
|
|
163
|
+
let arrival = packet(
|
|
164
|
+
ENS18,
|
|
165
|
+
None,
|
|
166
|
+
17,
|
|
167
|
+
(CLIENT, 40000),
|
|
168
|
+
(UPLINK, port),
|
|
169
|
+
(CLIENT, UPLINK, 40000, port),
|
|
170
|
+
);
|
|
171
|
+
assert_eq!(decide(&ranged, "pre", &arrival).translation, None, "{port}");
|
|
172
|
+
// Even a flow translated by another owner meets the capture barrier.
|
|
173
|
+
let translated = Flow {
|
|
174
|
+
output: HANDOFF,
|
|
175
|
+
destination: TRANSIT,
|
|
176
|
+
..arrival
|
|
177
|
+
};
|
|
178
|
+
assert_eq!(decide(&ranged, "forward", &translated).verdict, 0, "{port}");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn router_range_classifies_translates_and_admits_every_port_and_costs_one_port() {
|
|
184
|
+
let single = prepared(published_router(json!([router_published_port(
|
|
185
|
+
"tcp", 20000, 20000
|
|
186
|
+
)])));
|
|
187
|
+
let ranged = prepared(published_router(json!([router_range("tcp", 20000, 20200)])));
|
|
188
|
+
assert_eq!(operations(&ranged), operations(&single));
|
|
189
|
+
for port in INSIDE {
|
|
190
|
+
let arrival = packet(
|
|
191
|
+
HANDOFF,
|
|
192
|
+
None,
|
|
193
|
+
6,
|
|
194
|
+
(CLIENT, 40000),
|
|
195
|
+
(TRANSIT, port),
|
|
196
|
+
(CLIENT, TRANSIT, 40000, port),
|
|
197
|
+
);
|
|
198
|
+
let classified = decide(&ranged, "raw_in", &arrival);
|
|
199
|
+
assert_eq!((classified.verdict, classified.zone), (1, 0), "{port}");
|
|
200
|
+
assert_eq!(
|
|
201
|
+
decide(&ranged, "pre", &arrival).translation,
|
|
202
|
+
Some(Translation {
|
|
203
|
+
kind: 1,
|
|
204
|
+
address: WORK,
|
|
205
|
+
ports: None,
|
|
206
|
+
}),
|
|
207
|
+
"{port}"
|
|
208
|
+
);
|
|
209
|
+
let translated = Flow {
|
|
210
|
+
output: WORK_LINK,
|
|
211
|
+
destination: WORK,
|
|
212
|
+
..arrival
|
|
213
|
+
};
|
|
214
|
+
assert_eq!(decide(&ranged, "forward", &translated).verdict, 1, "{port}");
|
|
215
|
+
let answer = reply(translated);
|
|
216
|
+
assert_eq!(decide(&ranged, "forward", &answer).verdict, 1, "{port}");
|
|
217
|
+
// The workload's published packets stay in the default zone ahead of the
|
|
218
|
+
// leased grant to any address on destination port 443.
|
|
219
|
+
let endpoint = decide(
|
|
220
|
+
&ranged,
|
|
221
|
+
"raw_in",
|
|
222
|
+
&Flow {
|
|
223
|
+
destination_port: 443,
|
|
224
|
+
..answer
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
assert_eq!((endpoint.verdict, endpoint.zone), (1, 0), "{port}");
|
|
228
|
+
}
|
|
229
|
+
for port in OUTSIDE {
|
|
230
|
+
let arrival = packet(
|
|
231
|
+
HANDOFF,
|
|
232
|
+
None,
|
|
233
|
+
6,
|
|
234
|
+
(CLIENT, 40000),
|
|
235
|
+
(TRANSIT, port),
|
|
236
|
+
(CLIENT, TRANSIT, 40000, port),
|
|
237
|
+
);
|
|
238
|
+
assert_eq!(decide(&ranged, "raw_in", &arrival).verdict, 0, "{port}");
|
|
239
|
+
assert_eq!(decide(&ranged, "pre", &arrival).translation, None, "{port}");
|
|
240
|
+
// Just outside the range the leased classifier still claims the workload.
|
|
241
|
+
let leased = decide(
|
|
242
|
+
&ranged,
|
|
243
|
+
"raw_in",
|
|
244
|
+
&packet(
|
|
245
|
+
WORK_LINK,
|
|
246
|
+
None,
|
|
247
|
+
6,
|
|
248
|
+
(WORK, port),
|
|
249
|
+
(CLIENT, 443),
|
|
250
|
+
(WORK, CLIENT, port, 443),
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
assert_eq!((leased.verdict, leased.zone), (1, 1), "{port}");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#[test]
|
|
258
|
+
fn router_symmetric_publications_open_from_their_ports_toward_unprotected_space_only() {
|
|
259
|
+
let ports = json!([
|
|
260
|
+
symmetric(router_range("udp", 20000, 20200)),
|
|
261
|
+
symmetric(router_published_port("udp", 5070, 5070)),
|
|
262
|
+
symmetric(router_published_port("tcp", 5070, 5070))
|
|
263
|
+
]);
|
|
264
|
+
let published = prepared(published_router(ports.clone()));
|
|
265
|
+
let mut plain = ports;
|
|
266
|
+
for entry in plain.as_array_mut().unwrap() {
|
|
267
|
+
entry.as_object_mut().unwrap().remove("symmetric");
|
|
268
|
+
}
|
|
269
|
+
let plain = prepared(published_router(plain));
|
|
270
|
+
// An admission jump, a reply admission and a translation per symmetric
|
|
271
|
+
// publication, and one shared state chain: ESTABLISHED and a NEW opening
|
|
272
|
+
// per protocol.
|
|
273
|
+
// One map with its elements, four admissions and one translation, whatever
|
|
274
|
+
// the number of symmetric publications.
|
|
275
|
+
assert_eq!(operations(&published), operations(&plain) + 7);
|
|
276
|
+
let outbound = |protocol: u8, port: u16, destination: [u8; 4]| {
|
|
277
|
+
packet(
|
|
278
|
+
WORK_LINK,
|
|
279
|
+
HANDOFF,
|
|
280
|
+
protocol,
|
|
281
|
+
(WORK, port),
|
|
282
|
+
(destination, 5060),
|
|
283
|
+
(WORK, destination, port, 5060),
|
|
284
|
+
)
|
|
285
|
+
};
|
|
286
|
+
for (protocol, port, span) in [
|
|
287
|
+
(17, 20000, (20000, 20200)),
|
|
288
|
+
(17, 20100, (20000, 20200)),
|
|
289
|
+
(17, 20200, (20000, 20200)),
|
|
290
|
+
(17, 5070, (5070, 5070)),
|
|
291
|
+
(6, 5070, (5070, 5070)),
|
|
292
|
+
] {
|
|
293
|
+
let flow = outbound(protocol, port, CLIENT);
|
|
294
|
+
// Without `symmetric` the flow from a published port is denied.
|
|
295
|
+
assert_eq!(decide(&plain, "forward", &flow).verdict, 0, "{port}");
|
|
296
|
+
assert_eq!(decide(&published, "forward", &flow).verdict, 1, "{port}");
|
|
297
|
+
let established = Flow {
|
|
298
|
+
state: ESTABLISHED,
|
|
299
|
+
tcp_flags: ACK,
|
|
300
|
+
..flow
|
|
301
|
+
};
|
|
302
|
+
assert_eq!(decide(&published, "forward", &established).verdict, 1);
|
|
303
|
+
assert_eq!(decide(&published, "forward", &reply(flow)).verdict, 1);
|
|
304
|
+
// Source NAT to the transit address and the published port span, which
|
|
305
|
+
// keeps the workload's port.
|
|
306
|
+
assert_eq!(
|
|
307
|
+
decide(&published, "post", &flow).translation,
|
|
308
|
+
snat(TRANSIT, span.0, span.1),
|
|
309
|
+
"{port}"
|
|
310
|
+
);
|
|
311
|
+
// Both directions stay in the default zone in raw classification.
|
|
312
|
+
assert_eq!(decide(&published, "raw_in", &flow).zone, 0);
|
|
313
|
+
let answer = Flow {
|
|
314
|
+
destination: TRANSIT,
|
|
315
|
+
..reply(flow)
|
|
316
|
+
};
|
|
317
|
+
let classified = decide(&published, "raw_in", &answer);
|
|
318
|
+
assert_eq!((classified.verdict, classified.zone), (1, 0), "{port}");
|
|
319
|
+
// The protected union stays denied: a protected current destination, a
|
|
320
|
+
// protected platform endpoint, and a protected original destination
|
|
321
|
+
// behind foreign destination NAT.
|
|
322
|
+
for denied in [
|
|
323
|
+
outbound(protocol, port, [192, 0, 2, 3]),
|
|
324
|
+
outbound(protocol, port, [203, 0, 113, 10]),
|
|
325
|
+
outbound(protocol, port, [10, 250, 0, 2]),
|
|
326
|
+
Flow {
|
|
327
|
+
original: (WORK, [192, 0, 2, 3], port, 5060),
|
|
328
|
+
..flow
|
|
329
|
+
},
|
|
330
|
+
] {
|
|
331
|
+
assert_eq!(decide(&published, "forward", &denied).verdict, 0, "{port}");
|
|
332
|
+
}
|
|
333
|
+
// A foreign zone, the reply direction and a changed original source never
|
|
334
|
+
// borrow the admission.
|
|
335
|
+
for foreign in [
|
|
336
|
+
Flow { zone: 7, ..flow },
|
|
337
|
+
Flow {
|
|
338
|
+
reply: true,
|
|
339
|
+
state: ESTABLISHED,
|
|
340
|
+
..flow
|
|
341
|
+
},
|
|
342
|
+
Flow {
|
|
343
|
+
original: (WORK, CLIENT, port + 1000, 5060),
|
|
344
|
+
..flow
|
|
345
|
+
},
|
|
346
|
+
] {
|
|
347
|
+
assert_ne!(decide(&published, "forward", &foreign).verdict, 1, "{port}");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// A TCP opening needs SYN with FIN/RST/ACK clear.
|
|
351
|
+
let tcp = outbound(6, 5070, CLIENT);
|
|
352
|
+
assert_eq!(
|
|
353
|
+
decide(
|
|
354
|
+
&published,
|
|
355
|
+
"forward",
|
|
356
|
+
&Flow {
|
|
357
|
+
tcp_flags: ACK,
|
|
358
|
+
..tcp
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
.verdict,
|
|
362
|
+
0
|
|
363
|
+
);
|
|
364
|
+
// Ports just outside the range and beside the single port stay denied.
|
|
365
|
+
for (protocol, port) in [(17, 20201), (17, 19999), (6, 5071)] {
|
|
366
|
+
assert_eq!(
|
|
367
|
+
decide(&published, "forward", &outbound(protocol, port, CLIENT)).verdict,
|
|
368
|
+
0,
|
|
369
|
+
"{port}"
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#[test]
|
|
375
|
+
fn host_symmetric_publications_open_from_their_target_ports_toward_unprotected_space_only() {
|
|
376
|
+
let ports = json!([
|
|
377
|
+
symmetric(host_range("udp", 20000, 20200)),
|
|
378
|
+
symmetric(published_port("udp", 5070, 5070)),
|
|
379
|
+
symmetric(published_port("tcp", 5080, 6080)),
|
|
380
|
+
published_port("udp", 8125, 8125)
|
|
381
|
+
]);
|
|
382
|
+
let published = prepared(published_host(ports.clone()));
|
|
383
|
+
let mut plain = ports;
|
|
384
|
+
for entry in plain.as_array_mut().unwrap() {
|
|
385
|
+
entry.as_object_mut().unwrap().remove("symmetric");
|
|
386
|
+
}
|
|
387
|
+
let plain = prepared(published_host(plain));
|
|
388
|
+
// One map with its elements, four admissions and one translation, whatever
|
|
389
|
+
// the number of symmetric publications.
|
|
390
|
+
assert_eq!(operations(&published), operations(&plain) + 7);
|
|
391
|
+
let outbound = |protocol: u8, port: u16, destination: [u8; 4]| {
|
|
392
|
+
packet(
|
|
393
|
+
HANDOFF,
|
|
394
|
+
ENS18,
|
|
395
|
+
protocol,
|
|
396
|
+
(TRANSIT, port),
|
|
397
|
+
(destination, 5060),
|
|
398
|
+
(TRANSIT, destination, port, 5060),
|
|
399
|
+
)
|
|
400
|
+
};
|
|
401
|
+
for (protocol, port, span) in [
|
|
402
|
+
(17, 20000, (20000, 20200)),
|
|
403
|
+
(17, 20100, (20000, 20200)),
|
|
404
|
+
(17, 20200, (20000, 20200)),
|
|
405
|
+
(17, 5070, (5070, 5070)),
|
|
406
|
+
// One port maps its target port to the published uplink port.
|
|
407
|
+
(6, 6080, (5080, 5080)),
|
|
408
|
+
] {
|
|
409
|
+
let flow = outbound(protocol, port, CLIENT);
|
|
410
|
+
assert_eq!(decide(&plain, "forward", &flow).verdict, 0, "{port}");
|
|
411
|
+
assert_eq!(decide(&published, "forward", &flow).verdict, 1, "{port}");
|
|
412
|
+
assert_eq!(
|
|
413
|
+
decide(&published, "forward", &reply(flow)).verdict,
|
|
414
|
+
1,
|
|
415
|
+
"{port}"
|
|
416
|
+
);
|
|
417
|
+
assert_eq!(
|
|
418
|
+
decide(&published, "post", &flow).translation,
|
|
419
|
+
snat(UPLINK, span.0, span.1),
|
|
420
|
+
"{port}"
|
|
421
|
+
);
|
|
422
|
+
for denied in [
|
|
423
|
+
outbound(protocol, port, [192, 0, 2, 3]),
|
|
424
|
+
outbound(protocol, port, [203, 0, 113, 10]),
|
|
425
|
+
Flow {
|
|
426
|
+
original: (TRANSIT, [192, 0, 2, 3], port, 5060),
|
|
427
|
+
..flow
|
|
428
|
+
},
|
|
429
|
+
] {
|
|
430
|
+
assert_eq!(decide(&published, "forward", &denied).verdict, 0, "{port}");
|
|
431
|
+
}
|
|
432
|
+
assert_eq!(
|
|
433
|
+
decide(&published, "forward", &Flow { zone: 7, ..flow }).verdict,
|
|
434
|
+
0
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
for (protocol, port) in [(17, 8125), (17, 20201), (6, 5080)] {
|
|
438
|
+
assert_eq!(
|
|
439
|
+
decide(&published, "forward", &outbound(protocol, port, CLIENT)).verdict,
|
|
440
|
+
0,
|
|
441
|
+
"{port}"
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
// Leased flows keep their own outer translation, without a port.
|
|
445
|
+
let leased = outbound(17, 10500, CLIENT);
|
|
446
|
+
assert_eq!(decide(&published, "forward", &leased).verdict, 1);
|
|
447
|
+
assert_eq!(
|
|
448
|
+
decide(&published, "post", &leased).translation,
|
|
449
|
+
Some(Translation {
|
|
450
|
+
kind: 0,
|
|
451
|
+
address: UPLINK,
|
|
452
|
+
ports: None,
|
|
453
|
+
})
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#[test]
|
|
458
|
+
fn host_publications_refuse_ambiguous_ranges_and_symmetric_conflicts() {
|
|
459
|
+
let accepted = |ports: Value| normalized(published_host(ports)).is_ok();
|
|
460
|
+
assert!(accepted(json!([host_range("udp", 20000, 20200)])));
|
|
461
|
+
// Adjacent to the leased range and to another publication, and the same span
|
|
462
|
+
// for another protocol.
|
|
463
|
+
assert!(accepted(json!([
|
|
464
|
+
host_range("udp", 11000, 11100),
|
|
465
|
+
host_range("udp", 11101, 11200),
|
|
466
|
+
host_range("tcp", 11000, 11100)
|
|
467
|
+
])));
|
|
468
|
+
// Two publications may share one target port while neither is symmetric.
|
|
469
|
+
assert!(accepted(json!([
|
|
470
|
+
published_port("tcp", 443, 8443),
|
|
471
|
+
published_port("tcp", 444, 8443)
|
|
472
|
+
])));
|
|
473
|
+
// A target port inside a leased range stays valid for a plain publication.
|
|
474
|
+
assert!(accepted(json!([published_port("tcp", 443, 10500)])));
|
|
475
|
+
for (name, ports) in [
|
|
476
|
+
(
|
|
477
|
+
"range with a differing inside port",
|
|
478
|
+
json!([changed(
|
|
479
|
+
host_range("udp", 20000, 20200),
|
|
480
|
+
"/targetPort",
|
|
481
|
+
21000
|
|
482
|
+
)]),
|
|
483
|
+
),
|
|
484
|
+
("inverted range", json!([host_range("udp", 20000, 19999)])),
|
|
485
|
+
("one-port range", json!([host_range("udp", 20000, 20000)])),
|
|
486
|
+
(
|
|
487
|
+
"range end beyond 65535",
|
|
488
|
+
json!([beyond(host_range("udp", 20000, 20001), "/hostPortEnd")]),
|
|
489
|
+
),
|
|
490
|
+
(
|
|
491
|
+
"overlap with the leased range start",
|
|
492
|
+
json!([host_range("udp", 9990, 10000)]),
|
|
493
|
+
),
|
|
494
|
+
(
|
|
495
|
+
"overlap with the leased range end",
|
|
496
|
+
json!([host_range("tcp", 10999, 11005)]),
|
|
497
|
+
),
|
|
498
|
+
(
|
|
499
|
+
"range overlapping a single port",
|
|
500
|
+
json!([
|
|
501
|
+
host_range("udp", 20000, 20200),
|
|
502
|
+
published_port("udp", 20100, 20100)
|
|
503
|
+
]),
|
|
504
|
+
),
|
|
505
|
+
(
|
|
506
|
+
"ranges sharing their boundary port",
|
|
507
|
+
json!([
|
|
508
|
+
host_range("udp", 20000, 20200),
|
|
509
|
+
host_range("udp", 20200, 20300)
|
|
510
|
+
]),
|
|
511
|
+
),
|
|
512
|
+
(
|
|
513
|
+
"symmetric target port inside a leased range",
|
|
514
|
+
json!([symmetric(published_port("tcp", 443, 10500))]),
|
|
515
|
+
),
|
|
516
|
+
(
|
|
517
|
+
"symmetric target port shared with another publication",
|
|
518
|
+
json!([
|
|
519
|
+
symmetric(published_port("tcp", 443, 8443)),
|
|
520
|
+
published_port("tcp", 444, 8443)
|
|
521
|
+
]),
|
|
522
|
+
),
|
|
523
|
+
] {
|
|
524
|
+
assert!(normalized(published_host(ports)).is_err(), "{name}");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#[test]
|
|
529
|
+
fn router_publications_refuse_ambiguous_ranges_and_symmetric_conflicts() {
|
|
530
|
+
let accepted = |ports: Value| normalized(published_router(ports)).is_ok();
|
|
531
|
+
assert!(accepted(json!([router_range("udp", 20000, 20200)])));
|
|
532
|
+
assert!(accepted(json!([
|
|
533
|
+
router_range("udp", 11000, 11100),
|
|
534
|
+
router_range("udp", 11101, 11200),
|
|
535
|
+
router_range("tcp", 11000, 11100)
|
|
536
|
+
])));
|
|
537
|
+
assert!(accepted(json!([
|
|
538
|
+
router_published_port("tcp", 443, 8443),
|
|
539
|
+
router_published_port("tcp", 444, 8443)
|
|
540
|
+
])));
|
|
541
|
+
for (name, ports) in [
|
|
542
|
+
(
|
|
543
|
+
"range with a differing inside port",
|
|
544
|
+
json!([changed(
|
|
545
|
+
router_range("udp", 20000, 20200),
|
|
546
|
+
"/endpointPort",
|
|
547
|
+
21000
|
|
548
|
+
)]),
|
|
549
|
+
),
|
|
550
|
+
("inverted range", json!([router_range("udp", 20000, 19999)])),
|
|
551
|
+
("one-port range", json!([router_range("udp", 20000, 20000)])),
|
|
552
|
+
(
|
|
553
|
+
"range end beyond 65535",
|
|
554
|
+
json!([beyond(router_range("udp", 20000, 20001), "/transitPortEnd")]),
|
|
555
|
+
),
|
|
556
|
+
(
|
|
557
|
+
"overlap with the leased range start",
|
|
558
|
+
json!([router_range("udp", 9990, 10000)]),
|
|
559
|
+
),
|
|
560
|
+
(
|
|
561
|
+
"overlap with the leased range end",
|
|
562
|
+
json!([router_range("tcp", 10999, 11005)]),
|
|
563
|
+
),
|
|
564
|
+
(
|
|
565
|
+
"range overlapping a single port",
|
|
566
|
+
json!([
|
|
567
|
+
router_range("udp", 20000, 20200),
|
|
568
|
+
router_published_port("udp", 20100, 20100)
|
|
569
|
+
]),
|
|
570
|
+
),
|
|
571
|
+
(
|
|
572
|
+
"ranges sharing their boundary port",
|
|
573
|
+
json!([
|
|
574
|
+
router_range("udp", 20000, 20200),
|
|
575
|
+
router_range("udp", 20200, 20300)
|
|
576
|
+
]),
|
|
577
|
+
),
|
|
578
|
+
(
|
|
579
|
+
"symmetric endpoint port shared with another publication",
|
|
580
|
+
json!([
|
|
581
|
+
symmetric(router_published_port("tcp", 443, 8443)),
|
|
582
|
+
router_published_port("tcp", 444, 8443)
|
|
583
|
+
]),
|
|
584
|
+
),
|
|
585
|
+
(
|
|
586
|
+
"symmetric endpoint range overlapping another publication",
|
|
587
|
+
json!([
|
|
588
|
+
symmetric(router_range("udp", 20000, 20200)),
|
|
589
|
+
router_published_port("udp", 8125, 20150)
|
|
590
|
+
]),
|
|
591
|
+
),
|
|
592
|
+
] {
|
|
593
|
+
assert!(normalized(published_router(ports)).is_err(), "{name}");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
fn link_json(index: u32, name: &str, kind: &str, address: &str) -> Value {
|
|
598
|
+
json!({"interfaceIndex":index,"interfaceName":name,"interfaceKind":kind,
|
|
599
|
+
"macAddress":format!("02:00:00:00:{:02x}:{:02x}", index / 256, index % 256),
|
|
600
|
+
"interfaceLinkIndex":index + 1000,"requiredIpv4Addresses":[address]})
|
|
601
|
+
}
|
|
602
|
+
/// A Pallet-shaped node: `workloads` veth workloads behind one router, each with
|
|
603
|
+
/// local DNS on its gateway and public TCP and UDP egress when `leased`, grants
|
|
604
|
+
/// to the first `platforms` of four platform endpoints, and `published` of them
|
|
605
|
+
/// with one to three publications; the first carries the SIP shape.
|
|
606
|
+
pub(super) fn node(
|
|
607
|
+
workloads: u32,
|
|
608
|
+
leased: bool,
|
|
609
|
+
platforms: usize,
|
|
610
|
+
published: u32,
|
|
611
|
+
symmetric_all: bool,
|
|
612
|
+
) -> (Value, Value) {
|
|
613
|
+
let catalog = [
|
|
614
|
+
("dns", "192.0.2.1", "udp", 53),
|
|
615
|
+
("registry", "192.0.2.10", "tcp", 443),
|
|
616
|
+
("relay", "192.0.2.11", "tcp", 443),
|
|
617
|
+
("metrics", "192.0.2.12", "tcp", 9100),
|
|
618
|
+
];
|
|
619
|
+
let mut endpoints = Vec::new();
|
|
620
|
+
let mut links = Vec::new();
|
|
621
|
+
let mut rules = Vec::new();
|
|
622
|
+
let mut grants = Vec::new();
|
|
623
|
+
let mut router_ports = Vec::new();
|
|
624
|
+
let mut host_ports = Vec::new();
|
|
625
|
+
let mut next_port = 30000;
|
|
626
|
+
for index in 0..workloads {
|
|
627
|
+
let name = format!("w{index}");
|
|
628
|
+
let gateway = format!("10.241.{index}.1");
|
|
629
|
+
let address = format!("10.241.{index}.2");
|
|
630
|
+
endpoints.push(
|
|
631
|
+
json!({"id":name,"interfaceIndex":10 + index,"interfaceName":name,
|
|
632
|
+
"interfaceKind":"veth","sourcePrefixes":[format!("{address}/32")]}),
|
|
633
|
+
);
|
|
634
|
+
links.push(link_json(10 + index, &name, "veth", &gateway));
|
|
635
|
+
for protocol in ["tcp", "udp"].into_iter().filter(|_| leased) {
|
|
636
|
+
rules.push(json!({"sourceEndpoint":name,"destinationEndpoint":null,
|
|
637
|
+
"sourcePrefix":format!("{address}/32"),"destinationPrefix":format!("{gateway}/32"),
|
|
638
|
+
"protocol":protocol,"sourcePort":null,"destinationPort":53}));
|
|
639
|
+
rules.push(json!({"sourceEndpoint":null,"destinationEndpoint":name,
|
|
640
|
+
"sourcePrefix":format!("{gateway}/32"),"destinationPrefix":format!("{address}/32"),
|
|
641
|
+
"protocol":protocol,"sourcePort":53,"destinationPort":null}));
|
|
642
|
+
grants.push(json!({"sourceEndpoint":name,"sourcePrefix":format!("{address}/32"),
|
|
643
|
+
"destination":{"kind":"public","prefix":"0.0.0.0/0"},"protocol":protocol,
|
|
644
|
+
"sourcePort":null,"destinationPort":null,"sourcePortRange":{"first":10000,"last":10999}}));
|
|
645
|
+
}
|
|
646
|
+
for (id, _, protocol, port) in &catalog[..platforms] {
|
|
647
|
+
grants.push(json!({"sourceEndpoint":name,"sourcePrefix":format!("{address}/32"),
|
|
648
|
+
"destination":{"kind":"platform","endpointId":id},"protocol":protocol,
|
|
649
|
+
"sourcePort":null,"destinationPort":port,"sourcePortRange":{"first":10000,"last":10999}}));
|
|
650
|
+
}
|
|
651
|
+
if index >= published {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
let shape: Vec<(&str, u16, Option<u16>)> = if index == 0 {
|
|
655
|
+
vec![
|
|
656
|
+
("udp", 5070, None),
|
|
657
|
+
("tcp", 5070, None),
|
|
658
|
+
("udp", 20000, Some(20200)),
|
|
659
|
+
]
|
|
660
|
+
} else {
|
|
661
|
+
(0..=(index % 3))
|
|
662
|
+
.map(|_| {
|
|
663
|
+
next_port += 1;
|
|
664
|
+
("tcp", next_port, None)
|
|
665
|
+
})
|
|
666
|
+
.collect()
|
|
667
|
+
};
|
|
668
|
+
for (protocol, port, end) in shape {
|
|
669
|
+
let symmetric = symmetric_all || index == 0;
|
|
670
|
+
let mut routed = json!({"protocol":protocol,"transitPort":port,"endpointPort":port,
|
|
671
|
+
"endpointAddress":address,"transitSourceAddress":"10.240.0.2","symmetric":symmetric});
|
|
672
|
+
let mut hosted = json!({"protocol":protocol,"hostPort":port,"targetPort":port,
|
|
673
|
+
"targetAddress":"10.240.0.2","hostIp":"192.0.2.2","symmetric":symmetric});
|
|
674
|
+
if let Some(end) = end {
|
|
675
|
+
routed["transitPortEnd"] = json!(end);
|
|
676
|
+
hosted["hostPortEnd"] = json!(end);
|
|
677
|
+
}
|
|
678
|
+
router_ports.push(routed);
|
|
679
|
+
host_ports.push(hosted);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
let protection = json!({"authorityDigest":format!("sha256:{}","b".repeat(64)),
|
|
683
|
+
"prefixes":["10.0.0.0/8","192.0.2.0/24"],
|
|
684
|
+
"platformEndpoints":catalog[..platforms.max(3)]
|
|
685
|
+
.iter()
|
|
686
|
+
.map(|(id, address, protocol, port)| json!({"id":id,"address":address,"protocol":protocol,"port":port}))
|
|
687
|
+
.collect::<Vec<_>>()});
|
|
688
|
+
let ranges = json!([{"protocol":"tcp","first":10000,"last":10999},{"protocol":"udp","first":10000,"last":10999}]);
|
|
689
|
+
let lease = json!({"id":"lease-a","generation":1,"digest":format!("sha256:{}","a".repeat(64))});
|
|
690
|
+
let router = json!({"schemaVersion":2,"revision":1,"scope":{"kind":"routerEgress",
|
|
691
|
+
"endpoints":endpoints,"rules":rules,"links":links,
|
|
692
|
+
"handoff":link_json(4,"handoff","veth","10.240.0.2"),"protection":protection,
|
|
693
|
+
"generations":[{"lease":lease,"transitSourceAddress":"10.240.0.2","sourcePortRanges":ranges,
|
|
694
|
+
"conntrackZone":1,"conntrackLabel":"a".repeat(32),"grants":grants}],
|
|
695
|
+
"publishedPorts":router_ports}});
|
|
696
|
+
let host = json!({"schemaVersion":2,"revision":1,"scope":{"kind":"hostTransit","protection":protection,
|
|
697
|
+
"handoffs":[{"link":link_json(4,"handoff","veth","10.240.0.1"),
|
|
698
|
+
"allocations":[{"lease":lease,"transitSourceAddress":"10.240.0.2","sourcePortRanges":ranges}]}],
|
|
699
|
+
"uplink":link_json(2,"ens18","ethernet","192.0.2.2"),"snatAddress":"192.0.2.2",
|
|
700
|
+
"publishedPorts":host_ports}});
|
|
701
|
+
(router, host)
|
|
702
|
+
}
|
|
703
|
+
fn without_publications(mut value: Value) -> Value {
|
|
704
|
+
value["scope"]
|
|
705
|
+
.as_object_mut()
|
|
706
|
+
.unwrap()
|
|
707
|
+
.remove("publishedPorts");
|
|
708
|
+
value
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/// The rule expressions of a prepared policy, without the digest stamps.
|
|
712
|
+
fn rule_expressions(value: Value) -> Vec<Vec<u8>> {
|
|
713
|
+
prepared(value)
|
|
714
|
+
.program("snft_published_range")
|
|
715
|
+
.unwrap()
|
|
716
|
+
.into_iter()
|
|
717
|
+
.filter(|(kind, _)| *kind == 6)
|
|
718
|
+
.map(|(_, attributes)| crate::wire::one(&attributes, 4).unwrap().value.clone())
|
|
719
|
+
.collect()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
#[test]
|
|
723
|
+
fn a_realistic_node_costs_constant_rules_and_bounded_elements() {
|
|
724
|
+
// Every workload with local DNS, public TCP and UDP egress, grants to four
|
|
725
|
+
// platform endpoints and one to three publications, the first with the SIP
|
|
726
|
+
// shape. The router's rules are the same for every size; its elements, and
|
|
727
|
+
// the host's publications, grow with the node.
|
|
728
|
+
for workloads in [1, 8, 32, 64] {
|
|
729
|
+
let (router, host) = node(workloads, true, 4, workloads, false);
|
|
730
|
+
for (hop, value) in [("router", router), ("host", host)] {
|
|
731
|
+
let (operations, rule_bytes, element_bytes) = cost(&prepared(value));
|
|
732
|
+
assert!(operations < 768 && rule_bytes <= 100_000 && rule_bytes + element_bytes <= 212_000);
|
|
733
|
+
println!(
|
|
734
|
+
"NODE_COST workloads={workloads} {hop} operations={operations} rule_bytes={rule_bytes} element_bytes={element_bytes} target={}",
|
|
735
|
+
rule_bytes + element_bytes
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
let (eight, _) = node(8, true, 4, 8, false);
|
|
740
|
+
let (sixty_four, _) = node(64, true, 4, 64, false);
|
|
741
|
+
assert_eq!(rule_expressions(eight), rule_expressions(sixty_four));
|
|
742
|
+
// The largest such node the target budget holds; beyond it the policy is
|
|
743
|
+
// refused before any kernel work.
|
|
744
|
+
let fits = |workloads: u32| {
|
|
745
|
+
let (router, _) = node(workloads, true, 4, workloads, false);
|
|
746
|
+
serde_json::from_value::<super::Policy>(router)
|
|
747
|
+
.unwrap()
|
|
748
|
+
.prepare()
|
|
749
|
+
.is_ok()
|
|
750
|
+
};
|
|
751
|
+
let capacity = (64..=128).take_while(|workloads| fits(*workloads)).last().unwrap();
|
|
752
|
+
assert!(!fits(capacity + 1));
|
|
753
|
+
println!("NODE_COST capacity workloads={capacity}");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
#[test]
|
|
757
|
+
fn publications_cost_constant_rules_on_a_node() {
|
|
758
|
+
// One publishing workload and eight compile exactly the same rules on both
|
|
759
|
+
// hops, whatever their publications.
|
|
760
|
+
let rules = |published: u32| {
|
|
761
|
+
let (router, host) = node(8, false, 0, published, true);
|
|
762
|
+
[rule_expressions(router), rule_expressions(host)]
|
|
763
|
+
};
|
|
764
|
+
assert_eq!(rules(1), rules(8));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
#[test]
|
|
768
|
+
fn the_publication_maximum_fits_both_budgets() {
|
|
769
|
+
// The input maximum of 64 publications per scope, every one a symmetric
|
|
770
|
+
// range of 100 ports, spread over eight workloads.
|
|
771
|
+
let (mut router, mut host) = node(8, false, 0, 0, false);
|
|
772
|
+
router["scope"]["publishedPorts"] = json!((0..64u16)
|
|
773
|
+
.map(|index| {
|
|
774
|
+
let first = 20000 + index * 100;
|
|
775
|
+
symmetric(json!({"protocol":if index % 2 == 0 { "udp" } else { "tcp" },
|
|
776
|
+
"transitPort":first,"transitPortEnd":first + 99,"endpointPort":first,
|
|
777
|
+
"endpointAddress":format!("10.241.{}.2", index % 8),"transitSourceAddress":"10.240.0.2"}))
|
|
778
|
+
})
|
|
779
|
+
.collect::<Vec<_>>());
|
|
780
|
+
host["scope"]["publishedPorts"] = json!((0..64u16)
|
|
781
|
+
.map(|index| {
|
|
782
|
+
let first = 20000 + index * 100;
|
|
783
|
+
symmetric(
|
|
784
|
+
json!({"protocol":if index % 2 == 0 { "udp" } else { "tcp" },
|
|
785
|
+
"hostPort":first,"hostPortEnd":first + 99,"targetPort":first,
|
|
786
|
+
"targetAddress":"10.240.0.2","hostIp":"192.0.2.2"}),
|
|
787
|
+
)
|
|
788
|
+
})
|
|
789
|
+
.collect::<Vec<_>>());
|
|
790
|
+
for (hop, value) in [("router", router), ("host", host)] {
|
|
791
|
+
let bare = cost(&prepared(without_publications(value.clone())));
|
|
792
|
+
let (operations, rule_bytes, element_bytes) = cost(&prepared(value));
|
|
793
|
+
assert!(
|
|
794
|
+
operations <= 768 && rule_bytes <= 100_000 && element_bytes <= 100_000,
|
|
795
|
+
"{hop}"
|
|
796
|
+
);
|
|
797
|
+
println!(
|
|
798
|
+
"PUBLISHED_WORST {hop} publications=64 operations={operations} (+{}) rule_bytes={rule_bytes} (+{}) element_bytes={element_bytes}",
|
|
799
|
+
operations - bare.0,
|
|
800
|
+
rule_bytes - bare.1
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
fn changed(mut value: Value, path: &str, replacement: u16) -> Value {
|
|
806
|
+
*value.pointer_mut(path).unwrap() = json!(replacement);
|
|
807
|
+
value
|
|
808
|
+
}
|
|
809
|
+
fn beyond(mut value: Value, path: &str) -> Value {
|
|
810
|
+
*value.pointer_mut(path).unwrap() = json!(65536);
|
|
811
|
+
value
|
|
812
|
+
}
|