rsiprtp 0.4.1

Modular SIP/RTP communications stack for Rust with Sans-IO state machines
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
//! Differential-test harness driver: runs the `parser_diff_oracle`
//! against fixed fixture corpora and runs sanity tests for the
//! oracle's normalization.
//!
//! The oracle itself (the `assert_equivalent` machinery and all
//! supporting types) lives at `tests/parser_diff_oracle/mod.rs` so it
//! can be shared with the M11 fuzz target
//! `fuzz/fuzz_targets/sip_message_parse_diff.rs`. See the oracle
//! module's docstring for the design note.
//!
//! See `wrk_docs/2026.05.03 - HLD - sip-parser-rewrite.md`.

use rsiprtp::sip::parser::Message as OurMessage;
use rsiprtp::sip::SipUri;

#[path = "parser_diff_oracle/mod.rs"]
mod oracle;

use oracle::{
    assert_both_reject, assert_equivalent, normalize_value, ours_contact_diff, ours_cseq_diff,
    ours_from_to_diff, ours_to_diff, ours_to_to_diff, ours_via_diff, rsip_contact_diff,
    rsip_cseq_diff, rsip_from_to_diff, rsip_to_diff, rsip_to_to_diff, rsip_via_diff,
    unquote_display_name, DiffContact,
};

// ---------------------------------------------------------------
// Tests against the mdsiprtp3 fixture corpus
// ---------------------------------------------------------------

#[test]
fn diff_mdsiprtp3_invite_with_via() {
    let bytes = include_bytes!("fixtures/mdsiprtp3/invite_with_via.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_mdsiprtp3_response_200_ok() {
    let bytes = include_bytes!("fixtures/mdsiprtp3/response_200_ok.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_mdsiprtp3_invite_with_body() {
    let bytes = include_bytes!("fixtures/mdsiprtp3/invite_with_body.sip");
    assert_equivalent(bytes);
}

// ---------------------------------------------------------------
// Tests against the hand-curated fixture corpus
// ---------------------------------------------------------------
//
// See `tests/fixtures/handcrafted/README.md` for the catalog. These
// exercise corners not covered by the mdsiprtp3 fixtures: compact-form
// headers, folded headers, multi-`Via`, authentication headers, and
// the REGISTER / ACK / CANCEL methods.

#[test]
fn diff_handcrafted_register_with_contact() {
    let bytes = include_bytes!("fixtures/handcrafted/register_with_contact.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_handcrafted_invite_compact_via() {
    let bytes = include_bytes!("fixtures/handcrafted/invite_compact_via.sip");
    assert_equivalent(bytes);
}

/// rsip 0.4 does NOT accept RFC 3261 §7.3.1 line folding — its
/// tokenizer rejects the SP-led continuation as a malformed header
/// line. Our parser correctly accepts it and merges the fold into a
/// single header value (see `framing::parse_header_block`'s folding
/// path, also covered by the unit test `test_parse_header_block_folding*`).
/// This is a surprising rsip behavior we deliberately differ from;
/// see the brief's triage policy ("mark `#[ignore]` with a comment").
/// When we drop rsip in M10 this test should be unmarked and the
/// equivalence check replaced with a direct on-our-parser assertion.
#[test]
#[ignore = "rsip 0.4 rejects RFC 3261 §7.3.1 line folding; our parser correctly accepts it"]
fn diff_handcrafted_invite_folded_subject() {
    let bytes = include_bytes!("fixtures/handcrafted/invite_folded_subject.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_handcrafted_response_407_with_proxy_authenticate() {
    let bytes = include_bytes!("fixtures/handcrafted/response_407_with_proxy_authenticate.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_handcrafted_ack_for_2xx() {
    let bytes = include_bytes!("fixtures/handcrafted/ack_for_2xx.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_handcrafted_cancel() {
    let bytes = include_bytes!("fixtures/handcrafted/cancel.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_handcrafted_response_with_multi_via() {
    let bytes = include_bytes!("fixtures/handcrafted/response_with_multi_via.sip");
    assert_equivalent(bytes);
}

// ---------------------------------------------------------------
// Tests against the RFC 4475 torture-test corpus (M6)
// ---------------------------------------------------------------
//
// See `tests/fixtures/rfc4475/README.md` for the catalog. These exercise
// the corner cases described in RFC 4475 §3 ("Valid Messages"). Each
// fixture is the byte-exact decode of the corresponding §A.1 message
// from RFC 4475 — we assert byte-for-byte conformance against the RFC's
// own canonical examples, not just "the same parser corner the §3.1
// paragraph describes". The bytes are protected from line-ending
// translation by `*.sip -text` in the repo-root `.gitattributes`.

/// §3.1.1.1 "A short tortuous INVITE": quoted display names with
/// embedded SP / quoted-pairs, parameter-name-only forms, header
/// values broken across lines via line folding, and heavy interior
/// whitespace (e.g. `SIP  /   2.0  /UDP`).
///
/// **Divergence pinned:** rsip 0.4 rejects the entire message at
/// tokenize-version time — the combination of folding + interior
/// whitespace in `Via:` defeats its tokenizer. RFC 3261 §7.3.1
/// (line folding) and §25.1 (LWS in HCOLON / param SEMI / etc.)
/// mandate acceptance of all of these forms, so this is a real
/// rsip-side spec deficiency. Our parser correctly accepts (see
/// `framing::parse_header_block` folding path; `name_addr::*`
/// surrounding-whitespace handling).
///
/// At M10 (rsip dropped from runtime deps), this test should be
/// retargeted to a direct on-our-parser assertion.
#[test]
fn diff_rfc4475_wsinv_rsip_rejects() {
    let bytes: &[u8] = include_bytes!("fixtures/rfc4475/wsinv.sip");
    let rs = rsip::SipMessage::try_from(bytes);
    assert!(
        rs.is_err(),
        "rsip 0.4 rejects RFC 4475 §3.1.1.1 wsinv torture test; \
         got Ok({rs:?}) — update this test if rsip changed",
    );
    let ours = OurMessage::parse(bytes);
    assert!(
        ours.is_ok(),
        "our parser must accept RFC 4475 §3.1.1.1 wsinv; got Err({ours:?})",
    );
}

/// §3.1.1.2 "Wide Range of Valid Characters": exotic but
/// token-grammar-legal characters in the method, Request-URI,
/// header field names, header field values, and parameters. The
/// method on this fixture is `` `!interesting-Method0123456789_*+`.%indeed'~` ``
/// — every byte legal under RFC 3261 §25.1 `token`, but not one of
/// the RFC 3261 §7.1 named methods.
///
/// **Both reject — for different reasons:**
/// * **Ours**: RFC 3261 §7.1 lists a closed set of method names
///   (REGISTER, INVITE, ACK, BYE, CANCEL, OPTIONS, plus extension
///   methods registered later); our `Method` enum mirrors that
///   closed set, so any token the enum doesn't know is rejected
///   with `Parse error: unknown method`. Per RFC 3261 §7.1 method
///   tokens MUST be tokens (which this fixture satisfies) but a
///   parser is entitled to reject method names it does not
///   understand — that's the "501 Not Implemented" path described
///   in RFC 4475 §3.1.1.2's prose.
/// * **rsip 0.4**: rejects at tokenize-version time
///   (`failed to tokenize version`) because its method tokenizer
///   doesn't accept the full §25.1 `token` character set (`!`,
///   `` ` ``, `'`, `~`, `*`, `+`, etc.). Same family as wsinv —
///   rsip's tokenizer is narrower than the RFC grammar.
///
/// The two rejections are equally valid responses to this fixture:
/// rsip's is a tokenize-time spec deficiency, ours is a deliberate
/// closed-set-of-known-methods policy. The honest assertion is
/// "both reject"; the asymmetry is in *why*, not in the answer.
/// If our `Method` enum ever opens up to accept arbitrary tokens
/// this test will need to flip into an asymmetric pin.
///
/// Note: the layer distinction (rsip = tokenizer; ours = semantic
/// policy) is documentary; the test body asserts only that both
/// parsers reject, consistent with the rest of this file's pin-test
/// style.
#[test]
fn diff_rfc4475_intmeth_both_reject() {
    let bytes = include_bytes!("fixtures/rfc4475/intmeth.sip");
    assert_both_reject("rfc4475_intmeth", bytes);
}

/// §3.1.1.3 "Valid use of the % escaping": escaped chars in user/
/// contact URIs.
///
/// **Divergence pinned (byte-perfect §A.1):** the canonical RFC 4475
/// `esc01` bytes line-fold the `Contact:` header — the URI sits on
/// the next line indented with two spaces, per RFC 3261 §7.3.1. rsip
/// 0.4's tokenizer rejects the SP-led continuation as a malformed
/// header line (the same wsinv/folding deficiency pinned by
/// `diff_rfc4475_wsinv_rsip_rejects`). Our parser correctly accepts
/// and merges the fold (see `framing::parse_header_block`'s folding
/// path).
///
/// The pre-Stage-A fixture had no folding so this was a plain
/// `assert_equivalent`; the byte-perfect §A.1 form exposes the same
/// rsip line-folding deficiency we already pin on wsinv. When rsip
/// is dropped at M10 this test should be retargeted to a direct
/// on-our-parser assertion.
#[test]
fn diff_rfc4475_esc01_rsip_rejects_folding() {
    let bytes: &[u8] = include_bytes!("fixtures/rfc4475/esc01.sip");
    let rs = rsip::SipMessage::try_from(bytes);
    assert!(
        rs.is_err(),
        "rsip 0.4 rejects RFC 4475 §3.1.1.3 esc01 (canonical bytes \
         line-fold the Contact header); got Ok({rs:?}) — update this \
         test if rsip changed",
    );
    let ours = OurMessage::parse(bytes);
    assert!(
        ours.is_ok(),
        "our parser must accept RFC 4475 §3.1.1.3 esc01; got \
         Err({ours:?})",
    );
}

#[test]
fn diff_rfc4475_escnull() {
    // §3.1.1.4 "Escaped Nulls in URIs": `%00` in user portion.
    let bytes = include_bytes!("fixtures/rfc4475/escnull.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_rfc4475_esc02() {
    // §3.1.1.5 "Use of % when it is not an escape": `%` followed by
    // non-hex inside header values.
    let bytes = include_bytes!("fixtures/rfc4475/esc02.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_rfc4475_lwsdisp() {
    // §3.1.1.6 "Message with No LWS between Display Name and `<`".
    let bytes = include_bytes!("fixtures/rfc4475/lwsdisp.sip");
    assert_equivalent(bytes);
}

/// §3.1.1.7 "Long values in header fields": exercises size-limit
/// path. Our defense-in-depth caps at 8192 per value; this fixture
/// sits well below that.
///
/// **Divergence pinned (byte-perfect §A.1):** the canonical RFC 4475
/// `longreq` bytes carry a Via-stack that includes entries with
/// interior whitespace around HCOLON (`V :  SIP/2.0/TCP …`,
/// `V  : SIP/2.0/TCP …`, `Via : …`, `Via  : …`) — RFC 3261 §25.1
/// `HCOLON = *( SP / HTAB ) ":" SWS` mandates acceptance of all of
/// these forms. rsip 0.4's tokenizer rejects them with the same
/// `failed to tokenize version` error pinned on wsinv (see
/// `diff_rfc4475_wsinv_rsip_rejects`). Our parser correctly accepts.
///
/// The pre-Stage-A fixture had a simpler Via-stack so this was a
/// plain `assert_equivalent`; the byte-perfect §A.1 form exposes the
/// same rsip HCOLON-whitespace deficiency we already pin on wsinv.
/// When rsip is dropped at M10 this test should be retargeted to a
/// direct on-our-parser assertion.
#[test]
fn diff_rfc4475_longreq_rsip_rejects_hcolon_whitespace() {
    let bytes: &[u8] = include_bytes!("fixtures/rfc4475/longreq.sip");
    let rs = rsip::SipMessage::try_from(bytes);
    assert!(
        rs.is_err(),
        "rsip 0.4 rejects RFC 4475 §3.1.1.7 longreq (canonical bytes \
         carry HCOLON-with-interior-whitespace forms in the Via \
         stack); got Ok({rs:?}) — update this test if rsip changed",
    );
    let ours = OurMessage::parse(bytes);
    assert!(
        ours.is_ok(),
        "our parser must accept RFC 4475 §3.1.1.7 longreq; got \
         Err({ours:?})",
    );
}

/// §3.1.1.8 "Extra trailing octets in a UDP datagram": the first
/// message has `Content-Length: 0` (no body), but the datagram
/// contains a fully-formed second request after that. RFC 4475
/// §3.1.1.8 says: *"Implementations should process the request and
/// ignore the extra bytes."*
///
/// **Divergence pinned:** rsip 0.4 ignores Content-Length and
/// captures the trailing octets as the body of the first request.
/// Our parser correctly truncates the body to Content-Length per
/// RFC 3261 §18.3. Both parse the framing, but produce different
/// `body`. Pin the asymmetry; tighten when rsip is dropped at M10.
#[test]
fn diff_rfc4475_dblreq_rsip_keeps_trailing() {
    let bytes: &[u8] = include_bytes!("fixtures/rfc4475/dblreq.sip");
    let ours = OurMessage::parse(bytes).expect("our parser accepts dblreq");
    let our_body: &Vec<u8> = match &ours {
        OurMessage::Request(r) => &r.body,
        OurMessage::Response(r) => &r.body,
    };
    assert!(
        our_body.is_empty(),
        "ours: Content-Length: 0 → body must be empty; got {} bytes",
        our_body.len(),
    );
    let rs = rsip::SipMessage::try_from(bytes).expect("rsip accepts dblreq framing");
    let rs_body = match &rs {
        rsip::SipMessage::Request(r) => &r.body,
        rsip::SipMessage::Response(r) => &r.body,
    };
    assert!(
        !rs_body.is_empty(),
        "rsip 0.4 captures trailing octets as body; if it now \
         truncates per Content-Length, this pin can be removed.",
    );
}

/// §3.1.1.9 "Semicolon-separated parameters in URI user part": RFC 3261 §25.1
/// `user-unreserved = "&" / "=" / "+" / "$" / "," / ";" / "?" /
/// "/"` — so `;` is a legal user character. The Request-URI
/// `sip:user;par=u%40example.net@example.com` therefore has user
/// `user;par=u%40example.net` (the `;` and `@` decoded inside the
/// user portion) and host `example.com`.
///
/// **Divergence pinned:** rsip 0.4 rejects the entire message —
/// its tokenizer treats `;` as the user/params boundary without
/// honoring the userinfo grammar. Our parser correctly splits at
/// the `@` first per RFC 3261 §19.1.1 (this M6 milestone fixed a
/// bug in our parser where `;` before `@` was ALSO treated as a
/// param separator — see `crates/rsiprtp/src/sip/uri.rs`
/// `parse()`).
#[test]
fn diff_rfc4475_semiuri_rsip_rejects() {
    let bytes: &[u8] = include_bytes!("fixtures/rfc4475/semiuri.sip");
    let ours = OurMessage::parse(bytes).expect("ours accepts semiuri");
    if let OurMessage::Request(r) = &ours {
        // Verify the URI parse produced the right user/host split.
        let uri = SipUri::parse(&r.uri).expect("our URI parses");
        assert_eq!(uri.user(), Some("user;par=u%40example.net"));
        assert_eq!(uri.host(), "example.com");
    } else {
        panic!("expected request");
    }
    let rs = rsip::SipMessage::try_from(bytes);
    assert!(
        rs.is_err(),
        "rsip 0.4 rejects URI with `;` in user part; got Ok({rs:?}) \
         — update this test if rsip changed",
    );
}

/// §3.1.1.10 "Varied and Unknown Transport Types in Via". RFC 3261
/// §20.42 grammar: `transport-param = "transport=" ( "udp" / "tcp"
/// / "sctp" / "tls" / other-transport )` where `other-transport =
/// token`. Unknown but token-shaped transports MUST be accepted at
/// parse time (consumers can route or reject as they see fit).
///
/// **Divergence pinned (typed-Via path only):** rsip 0.4's typed
/// Via parser rejects unknown transport tokens (`TUNA` in our
/// fixture). Tier-1 framing on both sides is fine; the divergence
/// is in `rsip::headers::Via::typed()`. Our parser accepts.
///
/// We assert the message frames cleanly on both sides, then assert
/// rsip's typed-Via specifically rejects the unknown-transport
/// variant. When rsip is dropped at M10 this test is retargeted.
#[test]
fn diff_rfc4475_transports_rsip_rejects_unknown_transport() {
    let bytes: &[u8] = include_bytes!("fixtures/rfc4475/transports.sip");
    // Tier-1 framing is clean on both sides.
    let _ours_msg = OurMessage::parse(bytes).expect("ours frames transports");
    let _rsip_msg = rsip::SipMessage::try_from(bytes).expect("rsip frames transports");
    // Tier-2 typed-Via on the unknown-transport entry: rsip rejects.
    let unknown_via = "SIP/2.0/TUNA t6.example.com;branch=z9hG4bK6";
    let r = rsip_via_diff(unknown_via);
    assert!(
        r.is_err(),
        "rsip 0.4 rejects unknown transport in typed Via; got \
         Ok({r:?}) — update this test if rsip changed",
    );
    let d = ours_via_diff(unknown_via).expect("ours typed-Via accepts unknown transport");
    assert_eq!(d.transport, "TUNA");
}

/// §3.1.1.11 "Multipart MIME Message": a `MESSAGE` request whose
/// body is a multipart/mixed payload — first part `text/plain`, second
/// part `application/octet-stream` carrying a binary (DER-encoded)
/// attachment. Tier-1 framing reads the headers (terminated with the
/// canonical `\r\n\r\n` separator) and captures the multipart body
/// verbatim as opaque bytes; we don't unpack the multipart content at
/// parse time.
///
/// Notable property of the byte-perfect §A.1 bytes: the body contains
/// three bare LF (`0x0a`) bytes that are not line terminators —
/// they're just `0x0a` octets inside the binary DER payload. The
/// header section uses CRLF correctly, so framing finds `\r\n\r\n`
/// before any of these bare LFs are seen, and they ride along
/// untouched in the body.
///
/// Both parsers accept and produce byte-equal bodies; `assert_equivalent`
/// covers it. The fixture's value is the explicit RFC 4475 §3.1.1.11
/// conformance claim, byte-for-byte from §A.1.
#[test]
fn diff_rfc4475_mpart01() {
    let bytes = include_bytes!("fixtures/rfc4475/mpart01.sip");
    assert_equivalent(bytes);
}

#[test]
fn diff_rfc4475_unreason() {
    // §3.1.1.12 "Unusual Reason Phrase": a 200 response whose
    // Reason-Phrase carries non-ASCII (UTF-8 Cyrillic) bytes, per
    // RFC 3261 §25.1 Reason-Phrase grammar admitting UTF8-NONASCII.
    let bytes = include_bytes!("fixtures/rfc4475/unreason.sip");
    assert_equivalent(bytes);
}

/// §3.1.1.13 "Empty Reason Phrase": a `SIP/2.0 100 \r\n` status
/// line where the Reason-Phrase is empty (just the trailing SP
/// after the status code, then CRLF). RFC 3261 §7.2 BNF
/// `Status-Line = SIP-Version SP Status-Code SP Reason-Phrase
/// CRLF` and §25.1 `Reason-Phrase = *(reserved / unreserved / ...)`
/// — the `*` makes the reason phrase legitimately empty. RFC 4475
/// §3.1.1.13 prose: *"A parser must accept this message."*
///
/// Companion to the existing `status_line_missing_sp_after_code_both_reject`
/// pin (which covers `SIP/2.0 202\r\n` — no SP, no reason). That
/// shape is invalid per the BNF; this one is valid (the SP is
/// present, the reason is just zero-length).
///
/// Both parsers accept this — `assert_equivalent` covers it. The
/// fixture's value is the explicit RFC 4475 §3.1.1.13 conformance
/// claim, byte-for-byte from §A.1.
#[test]
fn diff_rfc4475_noreason() {
    let bytes = include_bytes!("fixtures/rfc4475/noreason.sip");
    assert_equivalent(bytes);
}

// ---------------------------------------------------------------
// RFC 4475 §4 — Invalid Messages: both parsers MUST reject
// ---------------------------------------------------------------
//
// Per RFC 4475 §4 ("Invalid Messages"), each of these is malformed in
// a way that any conformant parser must reject. The harness assertion
// is "both parsers return Err" — if EITHER parser accepts, that's a
// real bug we want to surface.
//
// Fixtures live in `tests/fixtures/rfc4475_invalid/` (separate dir
// from the §3 valid set so the rejection-expectation is structurally
// explicit).

#[test]
fn diff_rfc4475_invalid_no_version() {
    // §4-style: request line missing the `SIP/2.0` token.
    let bytes = include_bytes!("fixtures/rfc4475_invalid/badaspec_no_version.sip");
    assert_both_reject("badaspec_no_version", bytes);
}

// NOTE: a "negative Content-Length" fixture was considered (RFC 4475
// §4 ncl) but dropped — both parsers store header values as strings
// and validate digits only when bounding the body, which is a
// typed-form / body-extraction concern rather than tier-1 framing.
// RFC 4475 §4 ncl really exercises tier-2 logic that this harness
// does not cover.

#[test]
fn diff_rfc4475_invalid_garbage_start() {
    // §4-style: start line is neither a valid request nor a valid
    // status line.
    let bytes = include_bytes!("fixtures/rfc4475_invalid/badaspec_garbage_start.sip");
    assert_both_reject("badaspec_garbage_start", bytes);
}

/// M8 reviewer scenario: a structurally-complete request whose
/// Request-URI is not a SIP/SIPS/TEL URI (here: `http://x`). RFC
/// 3261 §25 production for Request-URI is `SIP-URI / SIPS-URI /
/// absoluteURI`, but absoluteURI does not include arbitrary
/// non-SIP schemes by default in the message router — and more
/// importantly, our `SipRequest::uri()` accessor calls
/// `SipUri::parse` which only knows `sip` / `sips` / `tel`. Before
/// the M8 framing-time validation this fixture would survive
/// framing on our side and then panic in `SipRequest::uri()` —
/// attacker-controlled DoS. Our framer must now reject.
///
/// **Divergence pinned:** rsip 0.4 accepts `http://x` and stores
/// the scheme as `Scheme::Other("http")`. We deliberately tighten
/// here: the fixture is rejected by us at framing time so the
/// downstream wrapper accessors are infallible. When rsip is
/// dropped from runtime deps at M10, this test should be
/// retargeted to a direct on-our-parser rejection assertion.
#[test]
fn diff_request_line_with_non_sip_uri_rsip_accepts_we_reject() {
    let bytes: &[u8] = b"INVITE http://x SIP/2.0\r\nCall-ID: x\r\nCSeq: 1 INVITE\r\n\
                         From: <sip:a>\r\nTo: <sip:b>\r\nVia: SIP/2.0/UDP h\r\n\
                         Max-Forwards: 70\r\nContent-Length: 0\r\n\r\n";
    let rs = rsip::SipMessage::try_from(bytes);
    assert!(
        rs.is_ok(),
        "rsip 0.4 accepts non-SIP Request-URIs as Scheme::Other; \
         got Err({rs:?}) — update this test if rsip tightened",
    );
    let ours = OurMessage::parse(bytes);
    assert!(
        ours.is_err(),
        "our framer must reject non-SIP Request-URI to keep \
         SipRequest::uri() panic-free; got Ok({ours:?})",
    );
}

/// M11 fuzz finding #10: rsip 0.4 accepts `SIP/x.y` for arbitrary
/// `x.y` in the status line (`SIP/1.0`, `SIP/1.25`, etc.). RFC 3261
/// §7.1 mandates *exactly* `SIP/2.0` ("The current SIP version is
/// 'SIP/2.0'. The version is case-sensitive."). Our parser was
/// tightened in M11 to enforce that, so we correctly reject these.
///
/// Note rsip is *internally inconsistent* — an earlier M11 finding
/// showed it rejects `SIP/0` while still accepting `SIP/1.x`,
/// suggesting a numeric-range check that allows some forms. The
/// fuzz oracle's known-asymmetry skip (see
/// `parser_diff_oracle::assert_equivalent`) prevents libfuzzer from
/// rediscovering variants of this each run.
///
/// **Divergence pinned:** rsip accepts, ours rejects. When rsip is
/// dropped at M10 this test should be retargeted to a direct
/// on-our-parser rejection assertion.
#[test]
fn typed_status_line_sip1_x_version_rsip_accepts_we_reject() {
    let bytes = b"SIP/1.0 200 OK\r\nCall-ID: x\r\nCSeq: 1 INVITE\r\n\
                  From: <sip:a>\r\nTo: <sip:b>\r\nVia: SIP/2.0/UDP h\r\n\
                  Content-Length: 0\r\n\r\n";
    let rs = rsip::SipMessage::try_from(&bytes[..]);
    assert!(
        rs.is_ok(),
        "rsip 0.4 accepts SIP/1.0 status lines; got Err({rs:?}) — \
         update this test if rsip stops accepting non-2.0 versions",
    );
    let ours = OurMessage::parse(bytes);
    assert!(
        ours.is_err(),
        "our parser must reject non-SIP/2.0 per RFC 3261 §7.1; \
         got Ok({ours:?})",
    );
}

/// M11 fuzz finding #11: rsip 0.4 silently strips a leading `\r\n`
/// from the body when the wire bytes contain a third CRLF immediately
/// after the `\r\n\r\n` headers/body separator. RFC 3261 §7.5 says
/// the body is exactly the bytes that follow the CRLF that terminates
/// the headers — a third CRLF is *part of the body*. Our parser
/// preserves the leading CRLF (correct); rsip drops it.
///
/// **Divergence pinned:** both accept; rsip's body is 2 bytes shorter
/// (the leading `\r\n` is gone). The fuzz oracle's known-asymmetry
/// skip (see `parser_diff_oracle::assert_equivalent` `(Ok, Ok)` arm)
/// prevents libfuzzer from rediscovering this every run. Update this
/// test if rsip stops stripping leading body CRLFs.
#[test]
fn body_leading_crlf_rsip_strips_we_preserve() {
    // Reproduces M11 fuzz finding #11. The trigger: a response with
    // no `Content-Length` header where the bytes immediately after
    // the `\r\n\r\n` headers/body separator begin with another `\r\n`.
    // rsip 0.4 silently strips that leading `\r\n` (drops 2 bytes off
    // the front of the body); our parser preserves it. RFC 3261 §7.5
    // says the body is *exactly* the bytes that follow the separator
    // — a third CRLF belongs to the body.
    let bytes = b"SIP/2.0 200 OK\r\n\r\n\r\nHi!";
    //                              ^^^^ headers/body separator
    //                                   ^^^^ leading CRLF of body
    //                                       ^^^ body content
    let rs = rsip_to_diff(bytes).expect("rsip should accept");
    let ours = ours_to_diff(bytes).expect("ours should accept");
    assert_eq!(
        ours.body, b"\r\nHi!",
        "ours preserves leading CRLF in body (RFC 3261 §7.5)",
    );
    assert_eq!(
        rs.body, b"Hi!",
        "rsip 0.4 strips the leading CRLF — update this test if rsip \
         stops stripping leading body CRLFs",
    );
    assert!(
        rs.body.len() < ours.body.len(),
        "rsip body must be strictly shorter than ours (the divergence \
         we're pinning); got rsip={:?} ours={:?}",
        rs.body,
        ours.body,
    );
}

/// M11 fuzz finding #13: rsip 0.4 silently swallows a bare LF
/// (without preceding CR) inside the Reason-Phrase, consuming
/// arbitrary text on the following line as part of the reason. RFC
/// 3261 §7.2 BNF mandates `Status-Line = SIP-Version SP Status-Code
/// SP Reason-Phrase CRLF` and `Reason-Phrase = *(reserved /
/// unreserved / escaped / UTF8-NONASCII / UTF8-CONT / SP / HTAB)` —
/// explicitly **not** including LF. The line terminator is CRLF
/// only.
///
/// On a wire input like `b"SIP/2.0 202 \nNotAHeader\r\n\r\n\xac "`,
/// rsip frames it as `Response { status: 202, reason: "\nNotAHeader",
/// headers: [], body: [0xAC, 0x20] }` — silently absorbing the bare
/// LF + bogus header text into the reason phrase. Our parser
/// correctly rejects: it recognizes the bare LF (or CRLF after the
/// trailing SP) as the end of the status line and then sees
/// `NotAHeader` as a malformed header line, surfacing
/// `Invalid header: missing ':' in header: NotAHeader`.
///
/// **Divergence pinned:** rsip accepts, ours rejects. The visible
/// "missing ':'" error from our parser is downstream of the real
/// rsip-side issue (bare-LF-as-part-of-reason). The fuzz oracle's
/// `(Ok, Err)` arm carries a `"missing ':'"` skip (see
/// `parser_diff_oracle::assert_equivalent`) to keep libfuzzer from
/// rediscovering variants of this every run. Update this test if
/// rsip stops absorbing bare LFs into the reason phrase.
#[test]
fn header_missing_colon_rsip_accepts_we_reject() {
    // The bare LF before "NotAHeader" is the trigger: rsip 0.4 eats
    // it as part of the reason phrase, our parser correctly treats
    // CR/LF as the line terminator per RFC 3261 §7.2. M11 fuzz
    // finding #13. Update this test if rsip stops swallowing bare
    // LFs in the status line.
    let bytes = b"SIP/2.0 202 \nNotAHeader\r\n\r\n\xac ";
    let rs = oracle::rsip_to_diff(bytes);
    let ours = oracle::ours_to_diff(bytes);
    assert!(rs.is_ok(), "rsip should accept (its real behavior)");
    let err = ours.expect_err("ours should reject");
    assert!(
        err.contains("missing ':'"),
        "ours error should mention missing colon (proxy for the \
         bare-LF-into-reason rsip bug); got: {err}",
    );
}

/// M11 round-trip oracle finding (round-trip #1): RFC 3261 §25.1's
/// `Reason-Phrase` grammar excludes CTL bytes (`%x00-1F / %x7F`)
/// other than HTAB. The parser previously accepted bare CR / NUL /
/// other CTL bytes that survived the framing layer (i.e. when not
/// recognised as line terminators); the serializer emitted them
/// verbatim onto the start line; the re-parse then broke because
/// the round-tripped bytes shifted framing. The round-trip oracle
/// caught this on the first run.
///
/// Fix: `parse_status_line` now rejects the full §25.1-disallowed
/// byte set. Companion to pin #13 — same RFC clause, different
/// detection surface (round-trip oracle vs. differential oracle).
///
/// **Divergence pinned:** rsip accepts, ours rejects. The fuzz
/// oracle's `(Ok, Err)` arm carries a
/// `"reason phrase contains forbidden control byte"` skip to keep
/// libfuzzer from rediscovering this every run. Update if rsip
/// tightens its tokenizer to match the §25.1 grammar.
#[test]
fn status_line_reason_ctl_byte_rsip_accepts_we_reject() {
    // Bare CR in the reason phrase. Survives `find_separator` (no
    // `\r\n`) and `split_first_line` (no `\r\n`, no bare `\n`), so
    // it reaches `parse_status_line`. RFC 3261 §25.1 disallows it.
    let bytes: &[u8] = b"SIP/2.0 200 ab\rcd\r\n\r\n";
    let rs = oracle::rsip_to_diff(bytes);
    let ours = oracle::ours_to_diff(bytes);
    assert!(
        rs.is_ok(),
        "rsip should accept (lenient §25.1): {:?}",
        rs.err()
    );
    let err = ours.expect_err("ours should reject CTL byte in reason");
    assert!(
        err.contains("reason phrase contains forbidden control byte"),
        "ours error should mention CTL byte in reason; got: {err}",
    );
}

/// M11 fuzz finding #14: a NUL byte (`0x00`) inside a header NAME
/// token in the header section. rsip 0.4's nom-based tokenizer
/// rejects it with a `Tokenizer error`; our parser accepts per the
/// M2-A pinned permissive policy (see
/// `crates/rsiprtp/src/sip/parser/framing.rs` ::
/// `test_header_with_embedded_nul_pinned_accepted`). RFC 3261 §7.3
/// does not strictly forbid NUL in header text, and §25.1 OCTET
/// grammar admits any byte; the rsip tokenizer is the narrower side
/// here.
///
/// Empirically rsip 0.4 accepts NUL inside a header *value*
/// (e.g. `Foo: a\0b`), inside the reason phrase, and inside the
/// body — so this pin uses NUL inside the header *name* token,
/// which is the position where rsip's tokenizer surfaces the
/// asymmetry as a `Tokenizer error` divergence (the original fuzz
/// finding's failing seeds matched this shape).
///
/// **Divergence pinned:** ours accepts, rsip rejects. This sits in
/// the same general category as findings #12 (status-line lenience)
/// and #13 (bare LF in Reason-Phrase): rsip's tokenizer is narrower
/// than RFC 3261 / our parser for non-printable bytes in the header
/// section. The fuzz oracle's `(Err, Ok)` arm now carries a
/// principled heuristic (any non-printable byte in the header
/// section combined with a rsip Tokenizer-class error) that catches
/// this whole category without needing per-error-string skips. See
/// `parser_diff_oracle::assert_equivalent`. Update this test if
/// rsip broadens its accepted character set (e.g. moves to a
/// byte-level OCTET tokenizer).
#[test]
fn header_section_contains_nul_rsip_rejects_we_accept() {
    // RFC 3261 §7.3 doesn't strictly forbid NUL bytes in header
    // section text; §25.1 OCTET grammar permits any byte. rsip 0.4's
    // tokenizer is narrower (rejects NUL inside the header NAME
    // token); we accept per M2-A's documented permissive policy. M11
    // fuzz finding #14. Update if rsip broadens its accepted
    // character set.
    let mut bytes = b"SIP/2.0 200 OK\r\nFo".to_vec();
    bytes.push(0); // NUL inside header NAME
    bytes.extend_from_slice(b"o: bar\r\nCall-ID: x\r\nCSeq: 1 INVITE\r\n");
    bytes.extend_from_slice(b"From: <sip:a>\r\nTo: <sip:b>\r\nVia: SIP/2.0/UDP h\r\n");
    bytes.extend_from_slice(b"Content-Length: 0\r\n\r\n");
    let rs = oracle::rsip_to_diff(&bytes);
    let ours = oracle::ours_to_diff(&bytes);
    assert!(rs.is_err(), "rsip should reject NUL in header NAME");
    let rs_err = rs.unwrap_err();
    assert!(
        rs_err.contains("Tokenizer error"),
        "rsip rejection should be Tokenizer-class (the heuristic skip \
         in parser_diff_oracle keys off this); got: {rs_err}",
    );
    assert!(
        ours.is_ok(),
        "we accept per OCTET grammar / M2-A permissive policy",
    );
}

/// M11 fuzz finding #12, closed: status lines that omit the SP
/// between Status-Code and Reason-Phrase when the reason is empty
/// (e.g. `"SIP/2.0 202\r\n"`). RFC 3261 §7.2 BNF
/// (`Status-Line = SIP-Version SP Status-Code SP Reason-Phrase
/// CRLF`) requires the SP — both SPs are mandatory. rsip 0.4
/// always rejected this; our parser previously stayed lenient via
/// `splitn(3, ' ')` (the third part defaulted to `""`), surfacing
/// as a one-accepts/one-rejects asymmetry. We tightened
/// `parse_status_line` to require the SP per the BNF (see the unit
/// test `test_status_line_missing_sp_after_code_rejects`), so both
/// parsers now reject and the asymmetry is closed.
///
/// History: previously a `(Err, Ok)` divergence pin
/// (`status_line_no_reason_sp_rsip_rejects_we_accept`) plus a
/// matching skip in `parser_diff_oracle`. After the framing
/// tightening, this is a both-reject case. The oracle's
/// `Tokenizer error + high-bit byte` skip is also retired — see
/// the `(Err, Ok)` arm in `parser_diff_oracle::assert_equivalent`.
#[test]
fn status_line_missing_sp_after_code_both_reject() {
    // RFC 3261 §7.2 BNF requires two SPs in Status-Line:
    // `Status-Line = SIP-Version SP Status-Code SP Reason-Phrase CRLF`
    // Previously rsip rejected this and ours accepted (M11 fuzz finding
    // #12); we tightened parse_status_line to match RFC strictly. Both
    // now reject — symmetric, no asymmetry to pin.
    let bytes = b"SIP/2.0 202\r\nCall-ID: x\r\nCSeq: 1 INVITE\r\n\
                  From: <sip:a>\r\nTo: <sip:b>\r\nVia: SIP/2.0/UDP h\r\n\
                  Content-Length: 0\r\n\r\n";
    assert_both_reject("status_line_missing_sp_after_code", bytes);
}

/// M11 fuzz finding #6: a bare LF in the start-line region (LF not
/// immediately preceded by CR, appearing before the first `\r\n`)
/// trips two mutually amplifying non-RFC behaviors and produces a
/// `(Ok, Ok)` divergence where both parsers accept the same status
/// code but disagree on framing.
///
/// The wire shape is:
/// `SIP/2.0 202 \n\n6.55554\r\n4:::::::::::*\r\n[13 body bytes]`.
/// Note there is **no** `\r\n\r\n` anywhere — only an LFLF after the
/// status line and single CRLFs separating the two header-like lines.
///
/// **rsip 0.4**: its status-line tokenizer (`response::Tokenizer`) is
/// `version SP status_code <reason via take_until("\r\n")> tag("\r\n")`.
/// `take_until("\r\n")` silently absorbs bare LFs — so the reason is
/// `"\n\n6.55554"` and the first `\r\n` after `6.55554` terminates
/// the status line. The header section is then `4:::::::::::*`
/// followed by a single CRLF, which the alt-fallback
/// `take_until("\r\n") + tag("\r\n")` consumes. rsip ends up with
/// one header `("4", "::::::::::*")` and a 13-byte body. RFC 3261
/// §7.2 BNF excludes LF from `Reason-Phrase`; rsip is non-strict.
/// Same family as M11 finding #13 (pinned via
/// `header_missing_colon_rsip_accepts_we_reject`).
///
/// **Our parser**: `framing::find_separator` prefers `\r\n\r\n` but
/// falls back to `\n\n` (deliberate compatibility leniency, pinned
/// by `framing::test_split_message_lf_only_fallback`). With no
/// `\r\n\r\n` in the input, we split at the LFLF immediately after
/// the status line. Header block is empty; body is the remaining 37
/// bytes — including `6.55554\r\n4:::::::::::*\r\n` which, viewed
/// as ASCII, looks like header lines. RFC 3261 §7.5 says the body
/// is what follows CRLFCRLF; our LFLF fallback is non-strict.
///
/// **Divergence pinned**: both accept; same status (202); different
/// framing (rsip 1 hdr / 13 body, ours 0 hdr / 37 body). The fuzz
/// oracle's `(Ok, Ok)` arm carries a `has_bare_lf_in_start_line`
/// skip that catches this whole family without enumerating wire
/// shapes (any bare LF before the first `\r\n` is the trigger).
/// When rsip is dropped at M10, this skip can be retired together
/// with the pin and the framing-side LFLF fallback can be
/// reconsidered (closing the divergence on our side too).
///
/// Update this test if rsip stops absorbing bare LFs in the
/// start-line region or if we tighten `find_separator` to reject
/// the LFLF fallback.
#[test]
fn body_starts_with_header_like_line_rsip_misinterprets() {
    // The trigger: a bare LF in the start-line region. Here the LFLF
    // immediately after `SIP/2.0 202 ` is what flips the framing on
    // our side; the SAME bytes are what rsip silently absorbs into
    // its reason phrase. M11 fuzz finding #6.
    let bytes: &[u8] = b"SIP/2.0 202 \n\n6.55554\r\n4:::::::::::*\r\n\x00\x00\x00z*4(\r\n@\nTT";
    let rs = oracle::rsip_to_diff(bytes).expect("rsip should accept");
    let ours = oracle::ours_to_diff(bytes).expect("ours should accept");
    // Same status, different framing.
    assert_eq!(rs.kind, ours.kind, "both parsers see Response 202");
    assert_eq!(
        rs.headers.len(),
        1,
        "rsip absorbs bare-LF prefix into reason and parses one header",
    );
    assert_eq!(rs.headers[0].0, "4", "rsip's single header is name=\"4\"",);
    assert_eq!(rs.body.len(), 13, "rsip's body is 13 bytes after CRLFCRLF");
    assert!(
        ours.headers.is_empty(),
        "ours splits at the LFLF after the status line, no headers",
    );
    assert_eq!(
        ours.body.len(),
        37,
        "ours's body is the prefix + the 13 trailing bytes (24 + 13)",
    );
    // Sanity: ours's body END must equal rsip's body (the same 13
    // trailing bytes). The 24-byte prefix is what rsip absorbed.
    assert_eq!(
        &ours.body[ours.body.len() - rs.body.len()..],
        &rs.body[..],
        "ours's body trails with the same 13 bytes rsip sees as body",
    );
}

// ---------------------------------------------------------------
// Tests against the rsiprtp fuzz corpus (populated by M11)
// ---------------------------------------------------------------

/// Diff every file in the rsiprtp fuzz corpus, if it exists.
///
/// The corpus directory is created and populated by M11's overnight fuzz
/// campaign. Until then this test is a no-op (vacuously passes). After
/// M11 lands, every fuzz-corpus input becomes a Tier-1 differential
/// assertion against rsip 0.4.
#[test]
fn diff_fuzz_corpus() {
    let corpus_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("fuzz")
        .join("sip_message_parse")
        .join("corpus");
    if !corpus_dir.exists() {
        // Corpus not yet populated; M11 owns this. No-op.
        return;
    }
    let entries: Vec<_> = std::fs::read_dir(&corpus_dir)
        .expect("corpus dir exists per check above")
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_file())
        .collect();
    for entry in entries {
        let path = entry.path();
        let bytes = std::fs::read(&path)
            .unwrap_or_else(|e| panic!("read fuzz corpus file {:?}: {}", path, e));
        // Wrap each file's assertion in its own panic message so a divergence
        // surfaces the file name, not just the bytes.
        let result = std::panic::catch_unwind(|| assert_equivalent(&bytes));
        if let Err(payload) = result {
            std::panic::resume_unwind(payload);
        }
    }
}

/// M11 fuzz finding #16 (run-#6 triage): rsip 0.4 silently absorbs a
/// bare LF inside a *header value*, consuming the following line's
/// bytes as part of the value. Same rsip-side bug class as finding
/// #13 (`header_missing_colon_rsip_accepts_we_reject`), which pins
/// the same behavior in the **reason-phrase** position; this pin
/// covers the **header-value** position. Future-proof: if rsip fixes
/// one tokenizer site but not the other, the unfixed site is still
/// caught.
///
/// On wire `b"SIP/2.0 200 OK\r\n6.~: 5\r-\n!S-*\x01\x0c~~~*\x0b* S54\r\n\r\n"`,
/// rsip frames it as a single header
/// `("6.~", "5\r-\n!S-*\u{1}\u{c}~~~*\u{b}* S54")` — bare CR and
/// bare LF and control bytes are silently absorbed into the value.
/// Our parser treats the bare LF as a line terminator (per
/// `str::lines` semantics; RFC 3261 §7.3 says CRLF terminates a
/// header), splits there, and surfaces the orphan continuation
/// `!S-*...` as a malformed header line — `Invalid header: missing
/// ':'`. Same downstream error string as pin #13, so the existing
/// oracle `(Ok, Err)` skip already catches the class; this test is
/// a regression guard for the specific position.
///
/// **Divergence pinned:** rsip accepts, ours rejects. Update if rsip
/// stops absorbing bare LFs inside header values.
#[test]
fn header_value_with_bare_lf_rsip_accepts_we_reject() {
    let bytes = b"SIP/2.0 200 OK\r\n6.~: 5\r-\n!S-*\x01\x0c~~~*\x0b* S54\r\n\r\n";
    let rs = oracle::rsip_to_diff(bytes);
    let ours = oracle::ours_to_diff(bytes);
    assert!(rs.is_ok(), "rsip should accept (its real behavior)");
    let err = ours.expect_err("ours should reject");
    assert!(
        err.contains("missing ':'"),
        "ours error should mention missing colon (proxy for the \
         bare-LF-into-value rsip bug); got: {err}",
    );
}

/// M11 fuzz finding #17 (run-#6 triage): the LFLF-separator
/// asymmetry — rsip rejects what we accept.
///
/// Our parser's `framing::find_separator` accepts a bare `\n\n` as a
/// header/body separator when no `\r\n\r\n` is present (deliberate
/// M2-A compatibility leniency, pinned by
/// `framing::test_split_message_lf_only_fallback`). rsip 0.4 has no
/// such fallback: when the wire lacks `\r\n\r\n`, rsip's nom-based
/// tokenizer treats the entire input as a header section and fails
/// on whatever can't be tokenized as SIP header syntax — typically
/// the bytes that we'd surface as the body.
///
/// Trigger: any `SIP/2.0 ... \n\n <bytes that don't tokenize as
/// headers>` shape. The body bytes are incidental — what matters is
/// LFLF being our recognized separator but not rsip's. The original
/// run-#6 finding's "sustained 0xb8/0xf8 bytes" was incidental: the
/// fuzzer landed on high-bit bytes because those are the cheapest
/// way to make rsip's tokenizer fail past the LFLF position. Any
/// non-tokenizer-friendly bytes would trigger the same shape.
///
/// **Divergence pinned:** rsip rejects (Tokenizer error), ours
/// accepts. The principled tokenizer-narrowness heuristic in
/// `parser_diff_oracle::assert_equivalent` misses this because it
/// scopes `header_section` via the same LFLF-fallback splitter — so
/// the "header section" the heuristic sees is pure ASCII (`SIP/2.0
/// 200 OK`) with no unusual bytes. The oracle now carries a separate
/// LFLF-asymmetry skip in the `(Err, Ok)` arm. Update both this
/// pin and the skip if our parser stops accepting the LFLF
/// fallback, or if rsip starts accepting it.
#[test]
fn lflf_separator_only_rsip_rejects_we_accept() {
    // Minimal trigger: SIP response, LFLF separator (no CRLFCRLF),
    // body bytes that don't tokenize as headers.
    let bytes = b"SIP/2.0 200 OK\n\n\xb8\xb8\xb8";
    let rs = oracle::rsip_to_diff(bytes);
    let ours = oracle::ours_to_diff(bytes);
    let rs_err = rs.expect_err("rsip should reject (no CRLFCRLF and high-bit body)");
    assert!(
        rs_err.contains("Tokenizer error"),
        "rsip rejection should be Tokenizer-class (the new LFLF \
         skip in parser_diff_oracle keys off this); got: {rs_err}",
    );
    let ours = ours.expect("ours should accept (LFLF fallback per M2-A)");
    assert_eq!(
        ours.body, b"\xb8\xb8\xb8",
        "ours surfaces the post-LFLF bytes as body",
    );
    // Sanity: the wire bytes have no CRLFCRLF, only LFLF.
    assert!(
        !bytes.windows(4).any(|w| w == b"\r\n\r\n"),
        "trigger requires no CRLFCRLF",
    );
    assert!(
        bytes.windows(2).any(|w| w == b"\n\n"),
        "trigger requires LFLF",
    );
}

// ---------------------------------------------------------------
// Sanity tests for the harness itself
// ---------------------------------------------------------------

#[test]
fn normalize_collapses_runs_of_whitespace() {
    assert_eq!(normalize_value("a   b  c"), "a b c");
    assert_eq!(
        normalize_value(" \tleading \t and trailing \t "),
        "leading and trailing"
    );
}

#[test]
fn normalize_strips_comments() {
    assert_eq!(normalize_value("foo (this is a comment) bar"), "foo bar");
    assert_eq!(
        normalize_value("Acme/1.0 (server (nested) detail) baz"),
        "Acme/1.0 baz",
    );
}

#[test]
fn normalize_preserves_quoted_strings() {
    // Parens inside a quoted string are NOT a comment.
    assert_eq!(
        normalize_value(r#""display (name)" <sip:a@b>"#),
        r#""display (name)" <sip:a@b>"#,
    );
}

#[test]
fn normalize_handles_quoted_pair_escapes() {
    // \" inside a quoted string is literal; doesn't end the string.
    assert_eq!(normalize_value(r#""a\"b" trailing"#), r#""a\"b" trailing"#,);
}

// ---------------------------------------------------------------
// Sanity tests for the typed-form (M4) normalization
// ---------------------------------------------------------------
//
// These exercise the DiffNameAddr normalization without needing a
// full SIP message — they construct the value strings inline. Two
// shapes are checked:
//
// 1. Parameter-order independence: `;tag=x;foo=y` should normalize
//    identically to `;foo=y;tag=x`. RFC 3261 §19.1.4 (URI params)
//    and §25.1's `*( SEMI generic-param )` production make this
//    legitimate at equality. Both rsip and our parser should
//    agree.
//
// 2. Quoted vs unquoted display name with a token-only inner: per
//    RFC 3261 §20.10/25.1, `"Alice" <sip:a@b>` and `Alice <sip:a@b>`
//    encode the same display-name "Alice". Our normalizer
//    (`unquote_display_name`) strips the quotes on the rsip side
//    so the two compare equal — this is the right call when the
//    inner content is a bare token (no characters that would
//    require quoting). For inner content that DOES require quoting
//    (e.g. spaces) the two are NOT equivalent — the unquoted form
//    is malformed under §25.1 and rsip and our parser would both
//    reject or interpret differently.

#[test]
fn typed_from_param_order_is_normalized() {
    let v1 = "Alice <sip:alice@example.com>;tag=x;foo=y";
    let v2 = "Alice <sip:alice@example.com>;foo=y;tag=x";
    let d1 = ours_from_to_diff(v1).unwrap();
    let d2 = ours_from_to_diff(v2).unwrap();
    assert_eq!(d1, d2, "param-order normalization failed: {d1:?} vs {d2:?}");
    let r1 = rsip_from_to_diff(v1).unwrap();
    let r2 = rsip_from_to_diff(v2).unwrap();
    assert_eq!(r1, r2);
    assert_eq!(d1, r1);
}

#[test]
fn typed_from_quoted_token_display_normalizes_to_unquoted() {
    // For a *token* inner (no chars requiring quotation) RFC 3261
    // §25.1's `display-name = *(token LWS) / quoted-string` lets
    // either form encode the same name.
    let v_quoted = r#""Alice" <sip:alice@example.com>;tag=t"#;
    let v_token = "Alice <sip:alice@example.com>;tag=t";
    let d_quoted = ours_from_to_diff(v_quoted).unwrap();
    let d_token = ours_from_to_diff(v_token).unwrap();
    assert_eq!(d_quoted, d_token);
    let r_quoted = rsip_from_to_diff(v_quoted).unwrap();
    let r_token = rsip_from_to_diff(v_token).unwrap();
    assert_eq!(r_quoted, r_token);
    assert_eq!(d_quoted, r_quoted);
}

#[test]
fn typed_from_quoted_with_space_preserves_inner() {
    // Inner has a space → MUST be quoted on the wire. The
    // normalized display name is the unquoted "Alice Smith".
    let v = r#""Alice Smith" <sip:a@b>;tag=t"#;
    let d = ours_from_to_diff(v).unwrap();
    let r = rsip_from_to_diff(v).unwrap();
    assert_eq!(d.display_name.as_deref(), Some("Alice Smith"));
    assert_eq!(d, r);
}

#[test]
fn typed_to_no_tag_is_normalized_consistently() {
    // To on initial INVITE has no tag — both parsers should accept.
    let v = "Bob <sip:bob@example.com>";
    let d = ours_to_to_diff(v).unwrap();
    let r = rsip_to_to_diff(v).unwrap();
    assert_eq!(d, r);
    assert_eq!(d.display_name.as_deref(), Some("Bob"));
    assert!(d.parameters.is_empty());
}

#[test]
fn typed_from_bare_addr_spec_normalizes() {
    // No angle brackets, `;tag=` is a header param.
    let v = "sip:bob@example.com;tag=xyz";
    let d = ours_from_to_diff(v).unwrap();
    let r = rsip_from_to_diff(v).unwrap();
    assert_eq!(d, r);
    assert_eq!(d.display_name, None);
    assert_eq!(
        d.parameters,
        vec![("tag".to_string(), Some("xyz".to_string()))]
    );
}

#[test]
fn unquote_display_name_handles_escapes() {
    assert_eq!(unquote_display_name(r#""Alice""#), "Alice");
    assert_eq!(unquote_display_name(r#""Alice Smith""#), "Alice Smith");
    assert_eq!(
        unquote_display_name(r#""He said \"hi\"""#),
        r#"He said "hi""#
    );
    assert_eq!(unquote_display_name("Alice"), "Alice"); // no quotes, pass-through
}

/// M4 follow-up (HLD note): quoted parameter values containing a
/// semicolon. RFC 3261 §25.1 `gen-value = token / host /
/// quoted-string`, so `;name="x;y"` is legal and the inner `;` is
/// NOT a parameter separator.
///
/// Investigation result (DA's flagged divergence): **rsip 0.4
/// rejects the entire input** when a generic-param value is a
/// quoted-string containing a `;`. Its `name_params` tokenizer
/// splits on `;` first, then runs token-only matching on the
/// segments — `name="x` is left over as "trailing input" and
/// fails. Our parser correctly honors the quoted-string boundary
/// (see `name_addr::parse_params`) and accepts the input.
///
/// This is a one-accepts / one-rejects case. Per the HLD's diff
/// triage policy ("spec is explicit (fix the wrong one)") the
/// spec is on our side: §25.1 `gen-value = token / host /
/// quoted-string`. We document the asymmetry here rather than
/// silently masking it. When rsip is dropped at M10 this test
/// becomes a direct on-our-parser assertion (no rsip side).
#[test]
fn typed_from_quoted_param_value_with_semicolon_rsip_rejects() {
    let v = r#"<sip:a@b>;tag=t;name="x;y""#;
    // Our parser accepts and produces two params, with the
    // semicolon-bearing value intact inside the quoted string.
    let d = ours_from_to_diff(v).unwrap();
    assert_eq!(d.parameters.len(), 2, "ours: {:?}", d.parameters);
    let name_value = d
        .parameters
        .iter()
        .find(|(k, _)| k == "name")
        .and_then(|(_, v)| v.as_deref());
    // The value retains its surrounding quotes (matches our
    // NameAddr behavior — see `test_quoted_param_value` there).
    assert_eq!(name_value, Some("\"x;y\""));
    // rsip rejects this. Pin that for documentation; if a future
    // rsip update fixes it the assertion will fire and we can
    // tighten the harness.
    let r = rsip_from_to_diff(v);
    assert!(
        r.is_err(),
        "rsip 0.4 rejects quoted-param-with-semicolon; \
         got Ok({r:?}) — update this test if rsip changed",
    );
}

/// Sister test: a quoted param value WITHOUT a semicolon. rsip
/// 0.4 still rejects this — its `name_params` tokenizer doesn't
/// model `gen-value = quoted-string` *at all* (not just the
/// semicolon-inside subcase). Confirms the divergence is broader
/// than the semicolon case.
///
/// Our parser accepts and stores the value with surrounding
/// quotes preserved; consumers who want the unquoted text apply
/// `unquote_display_name`-style stripping at the call site. We
/// pin both shapes here.
#[test]
fn typed_from_quoted_param_value_rsip_rejects_broadly() {
    let v = r#"<sip:a@b>;tag=t;name="hello""#;
    let d = ours_from_to_diff(v).unwrap();
    // Our parser keeps surrounding quotes verbatim.
    let name_value = d
        .parameters
        .iter()
        .find(|(k, _)| k == "name")
        .and_then(|(_, v)| v.as_deref());
    assert_eq!(name_value, Some("\"hello\""));
    let r = rsip_from_to_diff(v);
    assert!(
        r.is_err(),
        "rsip 0.4 rejects all quoted-string param values; got \
         Ok({r:?}) — update this test if rsip changed",
    );
}

// ---------------------------------------------------------------
// Sanity tests for the typed-form (M5) Via/CSeq/Contact path
// ---------------------------------------------------------------

#[test]
fn typed_via_basic_normalizes() {
    let v = "SIP/2.0/UDP host.example.com:5060;branch=z9hG4bK1";
    let d = ours_via_diff(v).unwrap();
    let r = rsip_via_diff(v).unwrap();
    assert_eq!(d, r);
    assert_eq!(d.protocol, "SIP/2.0");
    assert_eq!(d.transport, "UDP");
    assert_eq!(d.sent_by, "host.example.com:5060");
}

#[test]
fn typed_via_transport_case_normalized_to_upper() {
    let v_upper = "SIP/2.0/UDP host:5060;branch=z";
    let v_lower = "SIP/2.0/udp host:5060;branch=z";
    let d_upper = ours_via_diff(v_upper).unwrap();
    let d_lower = ours_via_diff(v_lower).unwrap();
    assert_eq!(d_upper.transport, d_lower.transport);
}

#[test]
fn typed_via_host_case_normalized_to_lower() {
    let v_lc = "SIP/2.0/UDP host.example.com:5060;branch=z";
    let v_uc = "SIP/2.0/UDP HOST.EXAMPLE.COM:5060;branch=z";
    let d_lc = ours_via_diff(v_lc).unwrap();
    let d_uc = ours_via_diff(v_uc).unwrap();
    assert_eq!(d_lc.sent_by, d_uc.sent_by);
}

/// RFC 3261 §20.42 + RFC 5118 §4.1: Via sent-by may be a bracketed
/// IPv6 reference, e.g. `[2001:db8::1]:5060`. **rsip 0.4 rejects
/// this** — its sent-by tokenizer doesn't model the
/// `IP6reference` production. Our parser accepts it (see
/// `via::test_parse_ipv6_with_port`).
///
/// Like the quoted-param-with-semicolon case, this is a
/// one-accepts/one-rejects divergence where the spec is on our
/// side. Pin the asymmetry; tighten when rsip is dropped at M10.
#[test]
fn typed_via_ipv6_rsip_rejects() {
    let v = "SIP/2.0/UDP [2001:db8::1]:5060;branch=z9hG4bKabc";
    let d = ours_via_diff(v).unwrap();
    assert_eq!(d.sent_by, "[2001:db8::1]:5060");
    let r = rsip_via_diff(v);
    assert!(
        r.is_err(),
        "rsip 0.4 rejects IPv6 sent-by; got Ok({r:?}) — \
         update this test if rsip changed",
    );
}

#[test]
fn typed_via_rport_flag_and_value_both_match_rsip() {
    // rport without value (client request): rsip stores as
    // Other("rport", None); we as ("rport", None).
    let v_flag = "SIP/2.0/UDP host:5060;branch=z;rport";
    let d_flag = ours_via_diff(v_flag).unwrap();
    let r_flag = rsip_via_diff(v_flag).unwrap();
    assert_eq!(d_flag, r_flag);

    // rport with value (server response).
    let v_val = "SIP/2.0/UDP host:5060;branch=z;rport=12345";
    let d_val = ours_via_diff(v_val).unwrap();
    let r_val = rsip_via_diff(v_val).unwrap();
    assert_eq!(d_val, r_val);
}

#[test]
fn typed_cseq_basic_normalizes() {
    let v = "1 INVITE";
    let d = ours_cseq_diff(v).unwrap();
    let r = rsip_cseq_diff(v).unwrap();
    assert_eq!(d, r);
    assert_eq!(d.seq, 1);
    assert_eq!(d.method, "INVITE");
}

#[test]
fn typed_cseq_method_case_normalized() {
    // rsip Display upper-cases; our Method::as_str() also upper.
    let v = "42 invite";
    let d = ours_cseq_diff(v).unwrap();
    let r = rsip_cseq_diff(v).unwrap();
    assert_eq!(d, r);
    assert_eq!(d.method, "INVITE");
}

#[test]
fn typed_cseq_high_seq_numbers() {
    let v = format!("{} BYE", u32::MAX);
    let d = ours_cseq_diff(&v).unwrap();
    let r = rsip_cseq_diff(&v).unwrap();
    assert_eq!(d, r);
    assert_eq!(d.seq, u32::MAX);
}

#[test]
fn typed_contact_simple_normalizes() {
    let v = "<sip:alice@example.com>;expires=3600";
    let d = ours_contact_diff(v).unwrap();
    let r = rsip_contact_diff(v).unwrap();
    assert_eq!(d, r);
    if let DiffContact::Addr(a) = &d {
        assert!(a.display_name.is_none());
    } else {
        panic!("expected Addr");
    }
}

#[test]
fn typed_contact_wildcard_handled_on_both_sides() {
    let v = "*";
    let d = ours_contact_diff(v).unwrap();
    let r = rsip_contact_diff(v).unwrap();
    assert!(matches!(d, DiffContact::Wildcard));
    assert!(matches!(r, DiffContact::Wildcard));
}

/// RFC 3261 §10.2.2 permits `Contact: *;expires=0` (wildcard with
/// parameters — the canonical REGISTER unbinding shape). Our
/// parser accepts and exposes `expires() == Some(0)` on a
/// typed `Wildcard { params: [...] }` variant. rsip 0.4 does NOT
/// model the wildcard at the typed level — instead it parses the
/// `*` as a literal `Domain("*")` host of an addr-spec URI, then
/// attaches the params to that fake addr. Both parsers "accept",
/// but the structural shape diverges. Pinned 9th rsip-side
/// deficiency: if rsip 0.4 is fixed to recognize the wildcard
/// shape (either as a dedicated typed variant or as a parse
/// rejection that defers to untyped), this assertion fires.
#[test]
fn typed_contact_wildcard_with_params_rsip_misclassifies() {
    use rsip::headers::untyped::{ToTypedHeader, UntypedHeader};
    let v = "*;expires=0";
    // Ours: wildcard variant with expires=0 in params.
    let ours = ours_contact_diff(v).unwrap();
    assert!(
        matches!(ours, DiffContact::Wildcard),
        "ours produced non-wildcard for `*;expires=0`: {ours:?}",
    );
    // rsip: typed-Contact accepts but classifies the `*` as a
    // domain-host. Verify that misclassification persists in 0.4
    // so a future rsip fix fires this pin.
    let untyped = rsip::headers::Contact::new(v);
    let rsip_typed = untyped.typed().expect("rsip 0.4 accepts `*;expires=0`");
    let rsip_host = rsip_typed.uri.host_with_port.host.to_string();
    assert_eq!(
        rsip_host, "*",
        "rsip 0.4 unexpectedly stopped misclassifying `*` as a Domain host: \
         host now = {rsip_host:?}; the wildcard-with-params divergence may be \
         fixed and this pin can be retired",
    );
}

#[test]
fn typed_contact_with_quoted_display_normalizes() {
    let v = r#""Alice" <sip:alice@example.com>;expires=300;q=0.7"#;
    let d = ours_contact_diff(v).unwrap();
    let r = rsip_contact_diff(v).unwrap();
    assert_eq!(d, r);
    if let DiffContact::Addr(a) = &d {
        assert_eq!(a.display_name.as_deref(), Some("Alice"));
    } else {
        panic!("expected Addr");
    }
}

#[test]
fn typed_contact_bare_addr_spec_normalizes() {
    let v = "sip:bob@example.com;expires=60";
    let d = ours_contact_diff(v).unwrap();
    let r = rsip_contact_diff(v).unwrap();
    assert_eq!(d, r);
}

#[test]
fn normalize_does_not_apply_quoted_pair_outside_string_or_comment() {
    // RFC 3261 §25.1: quoted-pair only valid inside quoted-string or comment.
    // Outside both, a backslash is a literal byte and the parens are real (not comment-start).
    let input = r"foo \(literal\) bar";
    let out = normalize_value(input);
    // The parens are NOT comments since they're not introduced by an unescaped '(',
    // they're escaped. But our parser doesn't escape; the check is that we don't
    // silently swallow the closing ')'. Concretely: output must contain both '(' and ')'.
    assert!(out.contains('('), "expected '(' preserved, got: {out:?}");
    assert!(out.contains(')'), "expected ')' preserved, got: {out:?}");
}

/// SIP fuzz finding (parallel-overnight, 2026-05-06): a malformed
/// header/body separator of the form `\n\r\n` (LF CR LF) — i.e. a
/// bare LF followed by a CRLF — which rsip 0.4 accepts as a valid
/// separator but our framer rejects. RFC 3261 §7.1 mandates the
/// separator is `CRLF CRLF` (`\r\n\r\n`); we tolerate `\n\n` for
/// legacy interoperability (see
/// `framing::test_split_message_lf_only_fallback`) but deliberately
/// do **not** accept the asymmetric `\n\r\n` shape.
///
/// Wire shape: `SIP/2.0 223 X\r\nH: V\n\r\nbody`. The header line
/// `H: V` is terminated by a bare `\n`; the next two bytes `\r\n`
/// then act as the (malformed) separator and `body` follows. There
/// is no `\r\n\r\n` (only one `\r` in the candidate region) and no
/// `\n\n` (the two `\n`s have a `\r` between them), so our
/// `find_separator` returns `None`.
///
/// **Divergence pinned:** rsip accepts, ours rejects with
/// `"no header/body separator found"`. Our parser is RFC-correct
/// here — rsip's framer is the more permissive side. Update this
/// test if rsip tightens its framer to require `\r\n\r\n` per §7.1,
/// or if we deliberately broaden our `find_separator` to accept the
/// `\n\r\n` shape.
#[test]
fn separator_lf_cr_lf_rsip_accepts_we_reject() {
    // RFC 3261 §7.1: header/body separator MUST be CRLF CRLF. Our
    // framer additionally tolerates `\n\n` for legacy peers but not
    // `\n\r\n`. rsip 0.4's framer is more permissive.
    let bytes: &[u8] = b"SIP/2.0 223 X\r\nH: V\n\r\nbody";
    let rs = oracle::rsip_to_diff(bytes);
    let ours = oracle::ours_to_diff(bytes);
    assert!(
        rs.is_ok(),
        "rsip should accept the \\n\\r\\n separator: {:?}",
        rs.err(),
    );
    let err = ours.expect_err("ours should reject the \\n\\r\\n separator");
    assert!(
        err.contains("no header/body separator"),
        "ours error should mention missing separator; got: {err}",
    );
}