@mark1russell7/harness 0.3.0 → 0.4.1

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.
@@ -15,8 +15,10 @@
15
15
  //! timeout makes the server answer only when the timeout runs out, so its reply can be the one in
16
16
  //! flight when a fault lands.
17
17
  //! stream: then wait for --go, and send the request --count times, each when the reply to the one
18
- //! before it has come and --interval-ms has passed: a distribution of reply times, for a fault
19
- //! whose outcome is one (loss, reordering). It ends with a done line.
18
+ //! before it is whole and --interval-ms has passed: a distribution of reply times, for a fault
19
+ //! whose outcome is one (loss, reordering). A reply is whole at its CRLF, or for a bulk string
20
+ //! after its length: so `GET big` keeps a long reply's many segments in flight, and the replied
21
+ //! line stamps its last byte. It ends with a done line.
20
22
 
21
23
  use prober::{errno_name, monotonic_ns, quantity};
22
24
  use serde_json::{json, Value};
@@ -87,6 +89,40 @@ fn readable(fd: i32, ms: i32) -> std::io::Result<bool> {
87
89
  }
88
90
  }
89
91
 
92
+ /// The whole length of a Redis reply from its first bytes, or None while its first line is not
93
+ /// all there: a simple string, an error or an integer ends at its CRLF, and a bulk string after
94
+ /// its length and one more CRLF (a null one, of length -1, at its first CRLF).
95
+ fn reply_length(head: &[u8]) -> Option<usize> {
96
+ let line = head.windows(2).position(|pair| pair == b"\r\n")? + 2;
97
+ if head.first() != Some(&b'$') {
98
+ return Some(line);
99
+ }
100
+ let length: i64 = std::str::from_utf8(&head[1..line - 2]).ok()?.parse().ok()?;
101
+ Some(if length < 0 { line } else { line + length as usize + 2 })
102
+ }
103
+
104
+ /// The reply a stream waits for: its first bytes until its length is known, and how much came.
105
+ #[derive(Default)]
106
+ struct Reply {
107
+ head: Vec<u8>,
108
+ length: Option<usize>,
109
+ got: usize,
110
+ }
111
+
112
+ impl Reply {
113
+ /// Takes the bytes of one read, and says whether the reply is whole.
114
+ fn take(&mut self, bytes: &[u8]) -> bool {
115
+ self.got += bytes.len();
116
+ if self.length.is_none() {
117
+ // a bulk string's first line is its length: 64 bytes hold any
118
+ let room = 64usize.saturating_sub(self.head.len());
119
+ self.head.extend_from_slice(&bytes[..bytes.len().min(room)]);
120
+ self.length = reply_length(&self.head);
121
+ }
122
+ matches!(self.length, Some(length) if self.got >= length)
123
+ }
124
+ }
125
+
90
126
  /// what in TCP_INFO is worth a line when it changes: the timers' own state, never the ages
91
127
  fn watched(info: &libc::tcp_info) -> [u32; 7] {
92
128
  [
@@ -186,6 +222,7 @@ fn run() -> Result<(), Box<dyn Error>> {
186
222
  // stream mode: requests sent, whether one waits for its reply, and when the next may go
187
223
  let mut sent: u32 = 0;
188
224
  let mut awaiting = false;
225
+ let mut reply = Reply::default();
189
226
  let mut next_at: u64 = 0;
190
227
  loop {
191
228
  if readable(fd, tick)? {
@@ -196,8 +233,10 @@ fn run() -> Result<(), Box<dyn Error>> {
196
233
  }
197
234
  Ok(n) => {
198
235
  emit("data", json!({ "n": n, "text": String::from_utf8_lossy(&buf[..n.min(40)]) }));
199
- if awaiting {
236
+ if awaiting && reply.take(&buf[..n]) {
237
+ emit("replied", json!({ "n": quantity(reply.got as u32, "B") }));
200
238
  awaiting = false;
239
+ reply = Reply::default();
201
240
  next_at = monotonic_ns() + args.interval.as_nanos() as u64;
202
241
  }
203
242
  }
@@ -261,3 +300,24 @@ fn main() {
261
300
  std::process::exit(1);
262
301
  }
263
302
  }
303
+
304
+ #[cfg(test)]
305
+ mod tests {
306
+ use super::*;
307
+
308
+ #[test]
309
+ fn a_reply_is_whole_at_its_crlf_or_after_its_bulk_length() {
310
+ assert_eq!(reply_length(b"+PONG\r\n"), Some(7));
311
+ assert_eq!(reply_length(b"+PON"), None);
312
+ assert_eq!(reply_length(b"$5\r\nhello\r\n"), Some(11));
313
+ assert_eq!(reply_length(b"$-1\r\n"), Some(5));
314
+ assert_eq!(reply_length(b"$1000000\r\nxx"), Some(1_000_012));
315
+ // a long reply in reads of any size: whole only at its last byte
316
+ let mut reply = Reply::default();
317
+ assert!(!reply.take(b"$10\r"));
318
+ assert!(!reply.take(b"\n01234"));
319
+ assert!(!reply.take(b"56789\r"));
320
+ assert!(reply.take(b"\n"));
321
+ assert_eq!(reply.got, 17);
322
+ }
323
+ }
@@ -30,6 +30,12 @@
30
30
  //! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
31
31
  //! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
32
32
  //! its 190 ms never reach the stall's length.
33
+ //!
34
+ //! `--via DEV` puts a filter rule in the FORWARD chain, for a router: its INPUT and OUTPUT see only
35
+ //! what the router itself sends and receives. The rule faces one side by the device toward it:
36
+ //! what comes from the peer arrives on DEV (`-i DEV -s PEER`), and what goes to it leaves on DEV
37
+ //! (`-o DEV -d PEER`). A REJECT then answers from the router: a reset spoofs the address the
38
+ //! segment was going to, and an ICMP error comes from the router's own address on DEV.
33
39
 
34
40
  use prober::monotonic_ns;
35
41
  use serde_json::json;
@@ -63,13 +69,15 @@ fn until(at: u64) -> Result<(), Box<dyn Error>> {
63
69
  }
64
70
  }
65
71
 
66
- /// The options after the positional arguments: `--at NS`, `--direction D`, `--code C`, `--above N`.
72
+ /// The options after the positional arguments: `--at NS`, `--direction D`, `--code C`, `--above N`,
73
+ /// `--via DEV`.
67
74
  #[derive(Default)]
68
75
  struct Options {
69
76
  at: Option<u64>,
70
77
  direction: Option<String>,
71
78
  code: Option<String>,
72
79
  above: Option<u32>,
80
+ via: Option<String>,
73
81
  }
74
82
 
75
83
  fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
@@ -82,6 +90,7 @@ fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
82
90
  "--direction" => out.direction = Some(value.clone()),
83
91
  "--code" => out.code = Some(value.clone()),
84
92
  "--above" => out.above = Some(value.parse()?),
93
+ "--via" => out.via = Some(device(value)?.to_string()),
85
94
  other => return Err(format!("unknown option {other}").into()),
86
95
  }
87
96
  }
@@ -91,12 +100,18 @@ fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
91
100
  /// The iptables rules of an action that is a filter rule, or None for an action that is not.
92
101
  fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn Error>> {
93
102
  let op = |set: &str| if action == set { "-I" } else { "-D" };
103
+ // what comes from the peer, and what goes to it: at an end, the INPUT and OUTPUT of its own
104
+ // sockets; at a router, what it forwards in and out of the device toward the peer
105
+ let (from, to) = match opts.via.as_deref() {
106
+ None => (format!("INPUT -s {peer}/32"), format!("OUTPUT -d {peer}/32")),
107
+ Some(dev) => (format!("FORWARD -i {dev} -s {peer}/32"), format!("FORWARD -o {dev} -d {peer}/32")),
108
+ };
94
109
  Ok(match action {
95
110
  "blackhole" | "clear" => {
96
111
  let op = op("blackhole");
97
112
  let direction = opts.direction.as_deref().unwrap_or("both");
98
- let input = format!("{op} INPUT -s {peer}/32 -j DROP\n");
99
- let output = format!("{op} OUTPUT -d {peer}/32 -j DROP\n");
113
+ let input = format!("{op} {from} -j DROP\n");
114
+ let output = format!("{op} {to} -j DROP\n");
100
115
  match direction {
101
116
  "both" => format!("{input}{output}"),
102
117
  "in" => input,
@@ -106,7 +121,7 @@ fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn
106
121
  }
107
122
  "rst" | "clear-rst" => {
108
123
  let op = op("rst");
109
- format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with tcp-reset\n")
124
+ format!("{op} {from} -p tcp -j REJECT --reject-with tcp-reset\n")
110
125
  }
111
126
  "reject" | "clear-reject" => {
112
127
  let op = op("reject");
@@ -117,14 +132,14 @@ fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn
117
132
  "admin" => "icmp-admin-prohibited",
118
133
  other => return Err(format!("unknown code {other}").into()),
119
134
  };
120
- format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with {with}\n")
135
+ format!("{op} {from} -p tcp -j REJECT --reject-with {with}\n")
121
136
  }
122
137
  // the long segments drop where they arrive: in the sender's own namespace, an OUTPUT drop
123
138
  // would fail its sends with EPERM rather than lose them on the way
124
139
  "mtu" | "clear-mtu" => {
125
140
  let op = op("mtu");
126
141
  let above = opts.above.ok_or("--above N")?;
127
- format!("{op} INPUT -s {peer}/32 -p tcp -m length --length {}:65535 -j DROP\n", above + 1)
142
+ format!("{op} {from} -p tcp -m length --length {}:65535 -j DROP\n", above + 1)
128
143
  }
129
144
  other => return Err(format!("unknown action {other}").into()),
130
145
  })
@@ -323,6 +338,33 @@ mod tests {
323
338
  assert!(rules("reject", peer, &Options::default()).is_err());
324
339
  }
325
340
 
341
+ #[test]
342
+ fn a_router_gets_forward_rules_that_face_one_device() {
343
+ let peer: Ipv4Addr = "10.0.0.2".parse().unwrap();
344
+ let via = |direction: Option<&str>| Options {
345
+ direction: direction.map(Into::into),
346
+ via: Some("eth0".into()),
347
+ ..Options::default()
348
+ };
349
+ assert_eq!(
350
+ rules("blackhole", peer, &via(None)).unwrap(),
351
+ "-I FORWARD -i eth0 -s 10.0.0.2/32 -j DROP\n-I FORWARD -o eth0 -d 10.0.0.2/32 -j DROP\n"
352
+ );
353
+ assert_eq!(rules("clear", peer, &via(Some("out"))).unwrap(), "-D FORWARD -o eth0 -d 10.0.0.2/32 -j DROP\n");
354
+ assert_eq!(
355
+ rules("rst", peer, &via(None)).unwrap(),
356
+ "-I FORWARD -i eth0 -s 10.0.0.2/32 -p tcp -j REJECT --reject-with tcp-reset\n"
357
+ );
358
+ let host = Options { code: Some("host".into()), ..via(None) };
359
+ assert_eq!(
360
+ rules("clear-reject", peer, &host).unwrap(),
361
+ "-D FORWARD -i eth0 -s 10.0.0.2/32 -p tcp -j REJECT --reject-with icmp-host-unreachable\n"
362
+ );
363
+ // a device name is letters, digits, dots and dashes: nothing else reaches the rules
364
+ assert!(options(&["--via".into(), "eth0;x".into()]).is_err());
365
+ assert_eq!(options(&["--via".into(), "eth1".into()]).unwrap().via.as_deref(), Some("eth1"));
366
+ }
367
+
326
368
  #[test]
327
369
  fn http_requests_take_only_a_method_a_path_and_a_length() {
328
370
  assert!(http_path("/proxies/redis/toxics").is_ok());
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/harness",
4
- "version": "0.3.0",
4
+ "version": "0.4.1",
5
5
  "description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, through a topology of routers and proxies, injects a fault, measures the kernel's own records and judges a prediction.",
6
6
  "keywords": [
7
7
  "fault-injection",
@@ -56,8 +56,8 @@
56
56
  "node": ">=22.19"
57
57
  },
58
58
  "dependencies": {
59
- "@mark1russell7/prober": "^0.1.0",
60
- "@mark1russell7/quantity": "^0.1.0",
59
+ "@mark1russell7/prober": "^0.3.1",
60
+ "@mark1russell7/quantity": "^0.2.0",
61
61
  "testcontainers": "12.2.0"
62
62
  },
63
63
  "peerDependencies": {