1use std::io::Write;
29use std::net::{IpAddr, SocketAddr};
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use bytes::Bytes;
36
37use crate::counters::Meters;
38use crate::target::TransportKind;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct HepConfig {
43 pub collector: SocketAddr,
45 pub capture_id: u32,
47}
48
49impl HepConfig {
50 #[must_use]
52 pub const fn new(collector: SocketAddr, capture_id: u32) -> Self {
53 Self {
54 collector,
55 capture_id,
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
62pub struct CaptureConfig {
63 pub path: PathBuf,
65 pub redact: bool,
71 pub queue: usize,
76 pub hep: Option<HepConfig>,
82}
83
84impl CaptureConfig {
85 #[must_use]
87 pub fn new(path: impl Into<PathBuf>) -> Self {
88 Self {
89 path: path.into(),
90 redact: true,
91 queue: 1024,
92 hep: None,
93 }
94 }
95
96 #[must_use]
98 pub fn with_hep(mut self, hep: HepConfig) -> Self {
99 self.hep = Some(hep);
100 self
101 }
102
103 #[must_use]
107 pub fn without_redaction(mut self) -> Self {
108 self.redact = false;
109 self
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Direction {
116 In,
118 Out,
120}
121
122impl Direction {
123 const fn as_str(self) -> &'static str {
124 match self {
125 Self::In => "in",
126 Self::Out => "out",
127 }
128 }
129}
130
131#[derive(Debug)]
133struct Record {
134 seq: u64,
136 at: SystemTime,
137 local: SocketAddr,
138 peer: SocketAddr,
139 transport: TransportKind,
140 direction: Direction,
141 bytes: Bytes,
142 redacted: bool,
144}
145
146#[derive(Debug)]
151pub(crate) struct Capture {
152 records: std::sync::mpsc::SyncSender<Record>,
153 seq: u64,
155 redact: bool,
156 failed: Arc<AtomicBool>,
161}
162
163impl Capture {
164 pub(crate) fn start(config: &CaptureConfig, meters: Arc<Meters>) -> std::io::Result<Self> {
170 let file = std::fs::File::create(&config.path)?;
171 let mut writer = std::io::BufWriter::new(file);
172 write_section_header(&mut writer)?;
173 writer.flush()?;
174
175 let (records, incoming) = std::sync::mpsc::sync_channel::<Record>(config.queue.max(1));
179 let failed = Arc::new(AtomicBool::new(false));
180 let flag = Arc::clone(&failed);
181 let path = config.path.clone();
182 let hep = config.hep;
183
184 std::thread::Builder::new()
185 .name("sipx-capture".to_owned())
186 .spawn(move || {
187 write_loop(&mut writer, &incoming, &meters, &flag, &path, hep);
188 })?;
189
190 Ok(Self {
191 records,
192 seq: 0,
193 redact: config.redact,
194 failed,
195 })
196 }
197
198 fn is_failed(&self) -> bool {
201 self.failed.load(Ordering::Relaxed)
202 }
203}
204
205fn write_loop(
207 writer: &mut std::io::BufWriter<std::fs::File>,
208 incoming: &std::sync::mpsc::Receiver<Record>,
209 meters: &Meters,
210 failed: &AtomicBool,
211 path: &Path,
212 hep: Option<HepConfig>,
213) {
214 let mut hep = hep.map(HepExporter::new);
215 while let Ok(record) = incoming.recv() {
216 if let Err(error) = write_packet(writer, &record).and_then(|()| writer.flush()) {
217 meters.capture_error();
220 failed.store(true, Ordering::Relaxed);
221 tracing::error!(
222 %error,
223 path = %path.display(),
224 "capture write failed; the capture is now off"
225 );
226 return;
227 }
228 if let Some(exporter) = hep.as_mut() {
229 exporter.export(&record, meters);
230 }
231 }
232 if let Err(error) = writer.flush() {
235 meters.capture_error();
236 tracing::warn!(%error, path = %path.display(), "capture could not be flushed at shutdown");
237 }
238}
239
240struct HepExporter {
246 socket: Option<std::net::UdpSocket>,
247 config: HepConfig,
248 warned: bool,
251}
252
253impl HepExporter {
254 fn new(config: HepConfig) -> Self {
255 let bind = if config.collector.is_ipv4() {
256 "0.0.0.0:0"
257 } else {
258 "[::]:0"
259 };
260 let socket = std::net::UdpSocket::bind(bind)
261 .and_then(|socket| {
262 socket.connect(config.collector)?;
263 socket.set_nonblocking(true)?;
264 Ok(socket)
265 })
266 .map_err(|error| {
267 tracing::warn!(
268 %error,
269 collector = %config.collector,
270 "HEP collector is unavailable; signalling capture will continue locally"
271 );
272 })
273 .ok();
274 let warned = socket.is_none();
275 Self {
276 socket,
277 config,
278 warned,
279 }
280 }
281
282 fn export(&mut self, record: &Record, meters: &Meters) {
283 let sent = encode_hep(record, self.config.capture_id).and_then(|datagram| {
284 let socket = self.socket.as_ref().ok_or_else(|| {
285 std::io::Error::new(
286 std::io::ErrorKind::NotConnected,
287 "HEP collector socket is unavailable",
288 )
289 })?;
290 let written = socket.send(&datagram)?;
291 if written == datagram.len() {
292 Ok(())
293 } else {
294 Err(std::io::Error::new(
295 std::io::ErrorKind::WriteZero,
296 "HEP datagram was not sent in full",
297 ))
298 }
299 });
300 match sent {
301 Ok(()) => meters.capture_hep_record(),
302 Err(error) => {
303 meters.capture_hep_drop();
304 if self.warned {
305 tracing::debug!(
306 %error,
307 collector = %self.config.collector,
308 "dropping HEP signalling export"
309 );
310 } else {
311 self.warned = true;
312 tracing::warn!(
313 %error,
314 collector = %self.config.collector,
315 "dropping HEP signalling export; calls and local capture continue"
316 );
317 }
318 }
319 }
320 }
321}
322
323fn hep_chunk(out: &mut Vec<u8>, kind: u16, value: &[u8]) -> std::io::Result<()> {
324 let length = u16::try_from(6usize.saturating_add(value.len()))
325 .map_err(|_| std::io::Error::other("HEP chunk is too large"))?;
326 out.extend_from_slice(&0u16.to_be_bytes());
327 out.extend_from_slice(&kind.to_be_bytes());
328 out.extend_from_slice(&length.to_be_bytes());
329 out.extend_from_slice(value);
330 Ok(())
331}
332
333fn encode_hep(record: &Record, capture_id: u32) -> std::io::Result<Vec<u8>> {
334 let (source, destination) = match record.direction {
335 Direction::In => (record.peer, record.local),
336 Direction::Out => (record.local, record.peer),
337 };
338 let protocol = match record.transport {
339 TransportKind::Udp | TransportKind::Quic => 17,
340 TransportKind::Tcp | TransportKind::Tls | TransportKind::Ws | TransportKind::Wss => 6,
341 };
342 let mut ordered = Vec::with_capacity(record.bytes.len().saturating_add(128));
343 match (source.ip(), destination.ip()) {
344 (IpAddr::V4(from), IpAddr::V4(to)) => {
345 hep_chunk(&mut ordered, 0x0001, &[2])?;
346 hep_chunk(&mut ordered, 0x0002, &[protocol])?;
347 hep_chunk(&mut ordered, 0x0003, &from.octets())?;
348 hep_chunk(&mut ordered, 0x0004, &to.octets())?;
349 }
350 (IpAddr::V6(from), IpAddr::V6(to)) => {
351 hep_chunk(&mut ordered, 0x0001, &[10])?;
352 hep_chunk(&mut ordered, 0x0002, &[protocol])?;
353 hep_chunk(&mut ordered, 0x0005, &from.octets())?;
354 hep_chunk(&mut ordered, 0x0006, &to.octets())?;
355 }
356 _ => {
357 return Err(std::io::Error::other(
358 "HEP endpoints use different IP families",
359 ));
360 }
361 }
362 hep_chunk(&mut ordered, 0x0007, &source.port().to_be_bytes())?;
363 hep_chunk(&mut ordered, 0x0008, &destination.port().to_be_bytes())?;
364 let since = record.at.duration_since(UNIX_EPOCH).unwrap_or_default();
365 let seconds = u32::try_from(since.as_secs() & u64::from(u32::MAX)).unwrap_or(0);
366 hep_chunk(&mut ordered, 0x0009, &seconds.to_be_bytes())?;
367 hep_chunk(&mut ordered, 0x000a, &since.subsec_micros().to_be_bytes())?;
368 hep_chunk(&mut ordered, 0x000b, &[1])?;
369 hep_chunk(&mut ordered, 0x000c, &capture_id.to_be_bytes())?;
370 hep_chunk(&mut ordered, 0x000f, &record.bytes)?;
371
372 let total = u16::try_from(6usize.saturating_add(ordered.len()))
373 .map_err(|_| std::io::Error::other("HEP datagram is too large"))?;
374 let mut datagram = Vec::with_capacity(usize::from(total));
375 datagram.extend_from_slice(b"HEP3");
376 datagram.extend_from_slice(&total.to_be_bytes());
377 datagram.extend_from_slice(&ordered);
378 Ok(datagram)
379}
380
381const BLOCK_SECTION_HEADER: u32 = 0x0A0D_0D0A;
387const BLOCK_INTERFACE: u32 = 0x0000_0001;
389const BLOCK_PACKET: u32 = 0x0000_0006;
391const BYTE_ORDER_MAGIC: u32 = 0x1A2B_3C4D;
393const LINKTYPE_RAW: u16 = 101;
395const OPT_COMMENT: u16 = 1;
397const OPT_TSRESOL: u16 = 9;
399const OPT_END: u16 = 0;
401const TSRESOL_NANOS: u8 = 9;
403const IP_PROTO_UDP: u8 = 17;
405
406const fn padded(len: usize) -> usize {
408 len.next_multiple_of(4)
409}
410
411fn padding(len: usize) -> &'static [u8] {
413 const ZEROS: [u8; 3] = [0; 3];
414 ZEROS.get(..padded(len).saturating_sub(len)).unwrap_or(&[])
415}
416
417fn write_block(out: &mut impl Write, kind: u32, body: &[u8]) -> std::io::Result<()> {
422 let total = u32::try_from(12usize.saturating_add(padded(body.len())))
423 .map_err(|_| std::io::Error::other("capture block too large"))?;
424 out.write_all(&kind.to_ne_bytes())?;
425 out.write_all(&total.to_ne_bytes())?;
426 out.write_all(body)?;
427 out.write_all(padding(body.len()))?;
428 out.write_all(&total.to_ne_bytes())
429}
430
431fn push_option(body: &mut Vec<u8>, code: u16, value: &[u8]) {
433 body.extend_from_slice(&code.to_ne_bytes());
434 let len = u16::try_from(value.len()).unwrap_or(u16::MAX);
435 body.extend_from_slice(&len.to_ne_bytes());
436 let value = value.get(..usize::from(len)).unwrap_or(value);
437 body.extend_from_slice(value);
438 body.extend_from_slice(padding(value.len()));
439}
440
441fn write_section_header(out: &mut impl Write) -> std::io::Result<()> {
443 let mut shb = Vec::new();
444 shb.extend_from_slice(&BYTE_ORDER_MAGIC.to_ne_bytes());
445 shb.extend_from_slice(&1u16.to_ne_bytes()); shb.extend_from_slice(&0u16.to_ne_bytes()); shb.extend_from_slice(&(-1i64).to_ne_bytes()); push_option(&mut shb, OPT_COMMENT, b"sipx signalling capture");
449 push_option(&mut shb, OPT_END, &[]);
450 write_block(out, BLOCK_SECTION_HEADER, &shb)?;
451
452 let mut idb = Vec::new();
453 idb.extend_from_slice(&LINKTYPE_RAW.to_ne_bytes());
454 idb.extend_from_slice(&0u16.to_ne_bytes()); idb.extend_from_slice(&0u32.to_ne_bytes()); push_option(&mut idb, OPT_TSRESOL, &[TSRESOL_NANOS]);
457 push_option(&mut idb, OPT_END, &[]);
458 write_block(out, BLOCK_INTERFACE, &idb)
459}
460
461fn write_packet(out: &mut impl Write, record: &Record) -> std::io::Result<()> {
464 let packet = synthesise(record);
465 let nanos = record
466 .at
467 .duration_since(UNIX_EPOCH)
468 .map_or(0u128, |since| since.as_nanos());
469 let timestamp = u64::try_from(nanos).unwrap_or(u64::MAX);
470
471 let len = u32::try_from(packet.len())
472 .map_err(|_| std::io::Error::other("captured message too large"))?;
473
474 let mut body = Vec::with_capacity(packet.len() + 64);
475 body.extend_from_slice(&0u32.to_ne_bytes()); body.extend_from_slice(&((timestamp >> 32) as u32).to_ne_bytes());
477 #[allow(
478 clippy::cast_possible_truncation,
479 reason = "the low half of the timestamp is exactly what this field is"
480 )]
481 body.extend_from_slice(&(timestamp as u32).to_ne_bytes());
482 body.extend_from_slice(&len.to_ne_bytes()); body.extend_from_slice(&len.to_ne_bytes()); body.extend_from_slice(&packet);
485 body.extend_from_slice(padding(packet.len()));
486 push_option(&mut body, OPT_COMMENT, comment(record).as_bytes());
487 push_option(&mut body, OPT_END, &[]);
488
489 write_block(out, BLOCK_PACKET, &body)
490}
491
492fn comment(record: &Record) -> String {
495 let mut comment = format!(
496 "seq={} dir={} transport={} local={} peer={}",
497 record.seq,
498 record.direction.as_str(),
499 record.transport.as_str(),
500 record.local,
501 record.peer,
502 );
503 if record.transport.is_secure() {
504 comment.push_str(" decrypted-in-process=yes");
505 }
506 if record.redacted {
507 comment.push_str(" redacted=yes");
508 }
509 comment
510}
511
512fn synthesise(record: &Record) -> Vec<u8> {
519 let (source, destination) = match record.direction {
520 Direction::In => (record.peer, record.local),
521 Direction::Out => (record.local, record.peer),
522 };
523 let payload = &record.bytes;
524 let udp_len = u16::try_from(8usize.saturating_add(payload.len())).unwrap_or(u16::MAX);
525
526 let mut packet = Vec::with_capacity(40 + 8 + payload.len());
527 match (source.ip(), destination.ip()) {
528 (IpAddr::V4(from), IpAddr::V4(to)) => {
529 let total =
530 u16::try_from(20usize.saturating_add(usize::from(udp_len))).unwrap_or(u16::MAX);
531 let mut header = Vec::with_capacity(20);
532 header.push(0x45); header.push(0); header.extend_from_slice(&total.to_be_bytes());
535 header.extend_from_slice(&0u16.to_be_bytes()); header.extend_from_slice(&0u16.to_be_bytes()); header.push(64); header.push(IP_PROTO_UDP);
539 header.extend_from_slice(&0u16.to_be_bytes()); header.extend_from_slice(&from.octets());
541 header.extend_from_slice(&to.octets());
542 let checksum = ones_complement(&header);
546 if let Some(slot) = header.get_mut(10..12) {
547 slot.copy_from_slice(&checksum.to_be_bytes());
548 }
549 packet.extend_from_slice(&header);
550 }
551 (IpAddr::V6(from), IpAddr::V6(to)) => {
552 packet.extend_from_slice(&0x6000_0000u32.to_be_bytes()); packet.extend_from_slice(&udp_len.to_be_bytes()); packet.push(IP_PROTO_UDP);
555 packet.push(64); packet.extend_from_slice(&from.octets());
557 packet.extend_from_slice(&to.octets());
558 }
559 _ => {}
563 }
564
565 packet.extend_from_slice(&source.port().to_be_bytes());
566 packet.extend_from_slice(&destination.port().to_be_bytes());
567 packet.extend_from_slice(&udp_len.to_be_bytes());
568 packet.extend_from_slice(&0u16.to_be_bytes());
571 packet.extend_from_slice(payload);
572 packet
573}
574
575fn ones_complement(header: &[u8]) -> u16 {
577 let mut sum: u32 = 0;
578 for pair in header.chunks(2) {
579 let high = u32::from(pair.first().copied().unwrap_or(0));
580 let low = u32::from(pair.get(1).copied().unwrap_or(0));
581 sum = sum.wrapping_add((high << 8) | low);
582 }
583 while sum >> 16 != 0 {
584 sum = (sum & 0xFFFF).wrapping_add(sum >> 16);
585 }
586 #[allow(
587 clippy::cast_possible_truncation,
588 reason = "the fold above leaves at most sixteen significant bits"
589 )]
590 let folded = sum as u16;
591 !folded
592}
593
594const REDACTED_PARAMS: &[&[u8]] = &[
622 b"response",
625 b"nextnonce",
627 b"rspauth",
628 b"pn-prid",
630 b"pn-param",
631 b"+sip.instance",
633];
634
635const AUTH_HEADERS: &[&[u8]] = &[
640 b"authorization",
641 b"proxy-authorization",
642 b"authentication-info",
643 b"proxy-authenticate",
644 b"www-authenticate",
645];
646
647const CONTACT_HEADERS: &[&[u8]] = &[b"contact", b"m"];
649
650const OPAQUE_SCHEMES: &[&[u8]] = &[b"bearer", b"basic"];
657
658const REDACTION: &[u8] = b"REDACTED";
660
661struct Line<'a> {
667 text: &'a [u8],
668 terminator: &'a [u8],
669}
670
671fn lines(message: &[u8]) -> Vec<Line<'_>> {
676 let mut out = Vec::new();
677 let mut start = 0usize;
678 let mut at = 0usize;
679 while at < message.len() {
680 let width = match message.get(at) {
681 Some(b'\r') if message.get(at.saturating_add(1)) == Some(&b'\n') => 2,
682 Some(b'\r' | b'\n') => 1,
683 _ => 0,
684 };
685 if width == 0 {
686 at = at.saturating_add(1);
687 continue;
688 }
689 let end = at.saturating_add(width);
690 out.push(Line {
691 text: message.get(start..at).unwrap_or(&[]),
692 terminator: message.get(at..end).unwrap_or(&[]),
693 });
694 at = end;
695 start = end;
696 }
697 if start < message.len() {
698 out.push(Line {
699 text: message.get(start..).unwrap_or(&[]),
700 terminator: &[],
701 });
702 }
703 out
704}
705
706fn is_wsp(byte: u8) -> bool {
707 byte == b' ' || byte == b'\t'
708}
709
710fn is_continuation(line: &[u8]) -> bool {
712 line.first().copied().is_some_and(is_wsp)
713}
714
715fn unfold(physical: &[Line<'_>], from: usize, to: usize, separator: &[u8]) -> Vec<u8> {
721 let mut logical = Vec::new();
722 for index in from..to {
723 let Some(line) = physical.get(index) else {
724 continue;
725 };
726 if index == from {
727 logical.extend_from_slice(line.text);
728 } else {
729 logical.extend_from_slice(separator);
730 logical.extend_from_slice(line.text.trim_ascii_start());
731 }
732 }
733 logical
734}
735
736fn header_name(line: &[u8]) -> Option<Vec<u8>> {
740 let at = line.iter().position(|byte| *byte == b':')?;
741 Some(line.get(..at)?.trim_ascii_end().to_ascii_lowercase())
742}
743
744pub(crate) fn redact(message: &[u8]) -> Option<Bytes> {
748 let physical = lines(message);
749 let mut out: Vec<u8> = Vec::with_capacity(message.len().saturating_add(16));
750 let mut changed = false;
751 let mut in_body = false;
752 let mut index = 0usize;
753
754 while index < physical.len() {
755 let Some(line) = physical.get(index) else {
756 break;
757 };
758
759 if in_body {
760 match redact_body_line(line.text) {
762 Some(redacted) => {
763 changed = true;
764 out.extend_from_slice(&redacted);
765 }
766 None => out.extend_from_slice(line.text),
767 }
768 out.extend_from_slice(line.terminator);
769 index = index.saturating_add(1);
770 continue;
771 }
772
773 if line.text.is_empty() {
776 in_body = true;
777 out.extend_from_slice(line.terminator);
778 index = index.saturating_add(1);
779 continue;
780 }
781
782 let mut end = index.saturating_add(1);
784 while physical
785 .get(end)
786 .is_some_and(|next| is_continuation(next.text))
787 {
788 end = end.saturating_add(1);
789 }
790
791 let redacted = redact_header(&unfold(&physical, index, end, b" ")).or_else(|| {
797 (end.saturating_sub(index) > 1)
798 .then(|| redact_header(&unfold(&physical, index, end, b"")))
799 .flatten()
800 });
801 match redacted {
802 Some(redacted) => {
803 changed = true;
804 out.extend_from_slice(&redacted);
807 out.extend_from_slice(b"\r\n");
808 }
809 None => {
810 for at in index..end {
812 if let Some(original) = physical.get(at) {
813 out.extend_from_slice(original.text);
814 out.extend_from_slice(original.terminator);
815 }
816 }
817 }
818 }
819 index = end;
820 }
821
822 changed.then(|| Bytes::from(out))
823}
824
825fn redact_header(line: &[u8]) -> Option<Vec<u8>> {
827 match header_name(line) {
828 Some(name) if AUTH_HEADERS.contains(&name.as_slice()) => redact_auth_header(line),
829 Some(name) if CONTACT_HEADERS.contains(&name.as_slice()) => redact_params(line, false),
830 Some(_) => None,
833 None => redact_params(line, false),
836 }
837}
838
839fn redact_auth_header(line: &[u8]) -> Option<Vec<u8>> {
841 let colon = line.iter().position(|byte| *byte == b':')?;
842 let after_colon = colon.saturating_add(1);
843 let value = line.get(after_colon..).unwrap_or(&[]);
844
845 let lead = value
847 .iter()
848 .position(|byte| !is_wsp(*byte))
849 .unwrap_or(value.len());
850 let token = value.get(lead..).unwrap_or(&[]);
851 let width = token
852 .iter()
853 .position(|byte| is_wsp(*byte))
854 .unwrap_or(token.len());
855 let scheme = token.get(..width).unwrap_or(&[]).to_ascii_lowercase();
856 let rest_at = after_colon.saturating_add(lead).saturating_add(width);
857 let rest = line.get(rest_at..).unwrap_or(&[]);
858
859 let opaque = OPAQUE_SCHEMES.contains(&scheme.as_slice())
860 || (!scheme.is_empty() && !rest.trim_ascii().is_empty() && !rest.contains(&b'='));
863
864 if opaque {
865 let mut redacted = Vec::with_capacity(line.len());
866 redacted.extend_from_slice(line.get(..rest_at).unwrap_or(&[]));
867 redacted.push(b' ');
868 redacted.extend_from_slice(REDACTION);
869 return Some(redacted);
870 }
871 redact_params(line, false)
872}
873
874fn redact_params(line: &[u8], preserve_len: bool) -> Option<Vec<u8>> {
879 let mut out = line.to_vec();
880 let mut changed = false;
881 for name in REDACTED_PARAMS {
882 while let Some(replaced) = redact_param(&out, name, preserve_len) {
883 out = replaced;
884 changed = true;
885 }
886 }
887 changed.then_some(out)
888}
889
890fn replacement(width: usize, preserve_len: bool) -> Vec<u8> {
892 if !preserve_len {
893 return REDACTION.to_vec();
894 }
895 let mut padded = Vec::with_capacity(width);
896 padded.extend_from_slice(REDACTION.get(..width.min(REDACTION.len())).unwrap_or(&[]));
897 while padded.len() < width {
898 padded.push(b'X');
899 }
900 padded
901}
902
903fn redact_param(line: &[u8], name: &[u8], preserve_len: bool) -> Option<Vec<u8>> {
907 let mut from = 0usize;
908 loop {
909 let at = find_ci(line, name, from)?;
910 let before_ok = at == 0
911 || line
912 .get(at.wrapping_sub(1))
913 .is_some_and(|byte| matches!(byte, b',' | b';' | b' ' | b'\t' | b'"' | b'='));
914 let mut cursor = at.saturating_add(name.len());
916 while line.get(cursor).is_some_and(u8::is_ascii_whitespace) {
917 cursor = cursor.saturating_add(1);
918 }
919 if !before_ok || line.get(cursor) != Some(&b'=') {
920 from = at.saturating_add(1);
921 continue;
922 }
923 cursor = cursor.saturating_add(1);
924 while line.get(cursor).is_some_and(u8::is_ascii_whitespace) {
925 cursor = cursor.saturating_add(1);
926 }
927
928 let quoted = line.get(cursor) == Some(&b'"');
929 let value_start = if quoted {
930 cursor.saturating_add(1)
931 } else {
932 cursor
933 };
934 let mut end = value_start;
935 while let Some(&byte) = line.get(end) {
936 if quoted {
937 if byte == b'\\' && line.get(end.saturating_add(1)).is_some() {
940 end = end.saturating_add(2);
941 continue;
942 }
943 if byte == b'"' {
944 break;
945 }
946 } else if matches!(byte, b',' | b';' | b' ' | b'\t') {
947 break;
948 }
949 end = end.saturating_add(1);
950 }
951
952 let value = line.get(value_start..end).unwrap_or(&[]);
953 if value == replacement(value.len(), preserve_len).as_slice() || value.is_empty() {
956 from = end.max(at.saturating_add(1));
957 continue;
958 }
959
960 let mut out = Vec::with_capacity(line.len());
961 out.extend_from_slice(line.get(..value_start).unwrap_or(&[]));
962 out.extend_from_slice(&replacement(value.len(), preserve_len));
963 out.extend_from_slice(line.get(end..).unwrap_or(&[]));
964 return Some(out);
965 }
966}
967
968fn redact_body_line(line: &[u8]) -> Option<Vec<u8>> {
974 if starts_with_ci(line, b"a=crypto:") {
975 return redact_inline_keys(line);
976 }
977 if starts_with_ci(line, b"k=") {
978 return redact_sdp_key(line);
979 }
980 match header_name(line) {
984 Some(name)
985 if AUTH_HEADERS.contains(&name.as_slice())
986 || CONTACT_HEADERS.contains(&name.as_slice()) =>
987 {
988 redact_params(line, true)
989 }
990 _ => None,
991 }
992}
993
994fn redact_inline_keys(line: &[u8]) -> Option<Vec<u8>> {
999 const INLINE: &[u8] = b"inline:";
1000 let mut out: Vec<u8> = Vec::with_capacity(line.len());
1001 let mut cursor = 0usize;
1002 let mut changed = false;
1003
1004 while let Some(found) = find_from(line, INLINE, cursor) {
1005 let value_start = found.saturating_add(INLINE.len());
1006 let mut end = value_start;
1007 while let Some(&byte) = line.get(end) {
1008 if matches!(byte, b'|' | b' ' | b'\t' | b';') {
1011 break;
1012 }
1013 end = end.saturating_add(1);
1014 }
1015 let width = end.saturating_sub(value_start);
1016 out.extend_from_slice(line.get(cursor..value_start).unwrap_or(&[]));
1017 if width > 0 {
1018 out.extend_from_slice(&replacement(width, true));
1019 changed = true;
1020 }
1021 cursor = end;
1022 }
1023 out.extend_from_slice(line.get(cursor..).unwrap_or(&[]));
1024 changed.then_some(out)
1025}
1026
1027fn redact_sdp_key(line: &[u8]) -> Option<Vec<u8>> {
1032 let colon = line.iter().position(|byte| *byte == b':')?;
1033 let value_start = colon.saturating_add(1);
1034 let width = line.len().saturating_sub(value_start);
1035 if width == 0 {
1036 return None;
1037 }
1038 let mut out = Vec::with_capacity(line.len());
1039 out.extend_from_slice(line.get(..value_start).unwrap_or(&[]));
1040 out.extend_from_slice(&replacement(width, true));
1041 Some(out)
1042}
1043
1044fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1046 if needle.is_empty() || haystack.len() < needle.len() {
1047 return None;
1048 }
1049 (from..=haystack.len().saturating_sub(needle.len()))
1050 .find(|&at| haystack.get(at..at.saturating_add(needle.len())) == Some(needle))
1051}
1052
1053fn find_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1055 if needle.is_empty() || haystack.len() < needle.len() {
1056 return None;
1057 }
1058 (from..=haystack.len().saturating_sub(needle.len())).find(|&at| {
1059 haystack
1060 .get(at..at.saturating_add(needle.len()))
1061 .is_some_and(|window| window.eq_ignore_ascii_case(needle))
1062 })
1063}
1064
1065fn starts_with_ci(haystack: &[u8], prefix: &[u8]) -> bool {
1066 haystack
1067 .get(..prefix.len())
1068 .is_some_and(|window| window.eq_ignore_ascii_case(prefix))
1069}
1070
1071impl Capture {
1072 pub(crate) fn observe_if_capturing(
1086 capture: Option<&mut Self>,
1087 meters: &Meters,
1088 local: SocketAddr,
1089 peer: SocketAddr,
1090 transport: TransportKind,
1091 direction: Direction,
1092 bytes: impl FnOnce() -> Bytes,
1093 ) {
1094 let Some(capture) = capture else {
1096 return;
1097 };
1098 if capture.is_failed() {
1099 return;
1100 }
1101 capture.observe(meters, &bytes(), local, peer, transport, direction);
1102 }
1103
1104 fn observe(
1105 &mut self,
1106 meters: &Meters,
1107 bytes: &Bytes,
1108 local: SocketAddr,
1109 peer: SocketAddr,
1110 transport: TransportKind,
1111 direction: Direction,
1112 ) {
1113 let (bytes, redacted) = if self.redact {
1114 match redact(bytes) {
1115 Some(clean) => (clean, true),
1116 None => (bytes.clone(), false),
1117 }
1118 } else {
1119 (bytes.clone(), false)
1120 };
1121
1122 self.seq = self.seq.saturating_add(1);
1123 let record = Record {
1124 seq: self.seq,
1125 at: SystemTime::now(),
1126 local,
1127 peer,
1128 transport,
1129 direction,
1130 bytes,
1131 redacted,
1132 };
1133 match self.records.try_send(record) {
1136 Ok(()) => meters.capture_record(),
1137 Err(_) => meters.capture_drop(),
1138 }
1139 }
1140}
1141
1142#[cfg(test)]
1143#[allow(
1144 clippy::unwrap_used,
1145 clippy::expect_used,
1146 clippy::panic,
1147 clippy::indexing_slicing
1148)]
1149mod tests {
1150 use super::*;
1151
1152 fn text(bytes: &[u8]) -> String {
1153 String::from_utf8_lossy(bytes).into_owned()
1154 }
1155
1156 #[test]
1159 fn a_digest_response_is_removed_and_the_challenge_is_kept() {
1160 let message = b"REGISTER sip:example.net SIP/2.0\r\n\
1161 Authorization: Digest username=\"alice\", realm=\"example.net\", \
1162 nonce=\"abc123\", uri=\"sip:example.net\", response=\"deadbeefcafe0001\", qop=auth\r\n\
1163 Content-Length: 0\r\n\r\n";
1164 let redacted = redact(message).expect("a digest response must be redacted");
1165 let out = text(&redacted);
1166
1167 assert!(
1168 !out.contains("deadbeefcafe0001"),
1169 "the digest response is still in the capture: {out}"
1170 );
1171 assert!(out.contains("response=\"REDACTED\""), "{out}");
1172 assert!(out.contains("realm=\"example.net\""), "{out}");
1175 assert!(out.contains("nonce=\"abc123\""), "{out}");
1176 assert!(out.contains("username=\"alice\""), "{out}");
1177 assert!(out.contains("qop=auth"), "{out}");
1178 assert!(
1179 out.starts_with("REGISTER sip:example.net SIP/2.0\r\n"),
1180 "{out}"
1181 );
1182 }
1183
1184 #[test]
1186 fn an_srtp_key_is_removed_without_changing_the_body_length() {
1187 let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
1188 Content-Type: application/sdp\r\n\
1189 Content-Length: 68\r\n\r\n\
1190 v=0\r\n\
1191 a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj|2^20|1:32\r\n";
1192 let redacted = redact(message).expect("an SRTP key must be redacted");
1193 let out = text(&redacted);
1194
1195 assert!(
1196 !out.contains("d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj"),
1197 "the SRTP master key is still in the capture: {out}"
1198 );
1199 assert!(
1200 out.contains("a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:"),
1201 "{out}"
1202 );
1203 assert!(out.contains("AES_CM_128_HMAC_SHA1_80"), "{out}");
1206 assert!(
1207 out.contains("|2^20|1:32"),
1208 "the lifetime and MKI are not secret: {out}"
1209 );
1210 assert_eq!(
1211 redacted.len(),
1212 message.len(),
1213 "body redaction must preserve length or Content-Length becomes a lie"
1214 );
1215 }
1216
1217 #[test]
1218 fn a_push_token_and_an_instance_urn_are_removed() {
1219 let message = b"REGISTER sip:example.net SIP/2.0\r\n\
1220 Contact: <sip:alice@192.0.2.4>;+sip.instance=\"<urn:uuid:00000000-0000-0000-0000-00000000042>\";\
1221 pn-provider=apns;pn-prid=SECRETTOKEN;pn-param=SECRETPARAM\r\n\
1222 Content-Length: 0\r\n\r\n";
1223 let out = text(&redact(message).expect("push credentials must be redacted"));
1224
1225 assert!(!out.contains("SECRETTOKEN"), "{out}");
1226 assert!(!out.contains("SECRETPARAM"), "{out}");
1227 assert!(!out.contains("urn:uuid:00000000"), "{out}");
1228 assert!(out.contains("pn-provider=apns"), "{out}");
1230 assert!(out.contains("sip:alice@192.0.2.4"), "{out}");
1231 }
1232
1233 #[test]
1236 fn redaction_keeps_what_makes_a_message_diagnosable() {
1237 let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
1238 Via: SIP/2.0/UDP 192.0.2.4:5060;branch=z9hG4bKtrace\r\n\
1239 From: \"Alice Example\" <sip:alice@example.net>;tag=abcd\r\n\
1240 To: <sip:bob@example.net>\r\n\
1241 Call-ID: trace@sipx\r\n\
1242 CSeq: 1 INVITE\r\n\
1243 Authorization: Digest response=\"secret\"\r\n\
1244 Content-Length: 0\r\n\r\n";
1245 let out = text(&redact(message).expect("the digest response is redacted"));
1246
1247 for kept in [
1248 "z9hG4bKtrace",
1249 "Alice Example",
1250 "sip:alice@example.net",
1251 "sip:bob@example.net",
1252 "trace@sipx",
1253 "CSeq: 1 INVITE",
1254 ] {
1255 assert!(
1256 out.contains(kept),
1257 "redaction removed {kept}, which it needs: {out}"
1258 );
1259 }
1260 assert!(!out.contains("\"secret\""), "{out}");
1261 }
1262
1263 #[test]
1266 fn a_message_with_no_credential_is_left_untouched() {
1267 let message = b"OPTIONS sip:example.net SIP/2.0\r\n\
1268 To: <sip:example.net>\r\n\
1269 Content-Length: 0\r\n\r\n";
1270 assert!(
1271 redact(message).is_none(),
1272 "nothing to redact must mean no copy"
1273 );
1274 }
1275
1276 #[test]
1279 fn a_display_name_that_looks_like_a_parameter_survives() {
1280 let message = b"INVITE sip:bob@example.net SIP/2.0\r\n\
1281 From: \"response=me\" <sip:alice@example.net>\r\n\
1282 Content-Length: 0\r\n\r\n";
1283 assert!(
1284 redact(message).is_none(),
1285 "a From display name is not a credential-bearing header"
1286 );
1287 }
1288
1289 #[test]
1292 fn a_truncated_message_is_redacted_without_panicking() {
1293 assert!(redact(b"").is_none());
1294 assert!(redact(b"\r\n").is_none());
1295 assert!(redact(b"Authorization: Digest response=").is_none());
1296 let partial = redact(b"Authorization: Digest response=\"abc")
1297 .expect("an unterminated quoted value is still a credential");
1298 assert!(!text(&partial).contains("abc"));
1299 let no_crlf = redact(b"Authorization: Digest response=\"xyz\"").expect("redacted");
1301 assert!(!text(&no_crlf).contains("xyz"));
1302 }
1303
1304 fn message(lines: &[&str]) -> Vec<u8> {
1308 joined(lines, "\r\n")
1309 }
1310
1311 fn joined(lines: &[&str], terminator: &str) -> Vec<u8> {
1313 let mut out = String::new();
1314 for line in lines {
1315 out.push_str(line);
1316 out.push_str(terminator);
1317 }
1318 out.push_str(terminator);
1319 out.into_bytes()
1320 }
1321
1322 #[test]
1327 fn every_legal_spelling_of_a_credential_header_is_redacted() {
1328 const SECRET: &str = "SPELLINGSECRET0001";
1329
1330 let folded = message(&[
1331 "REGISTER sip:example.net SIP/2.0",
1332 "Authorization: Digest username=\"alice\",",
1333 "\tresponse=\"SPELLINGSECRET0001\"",
1334 "Content-Length: 0",
1335 ]);
1336 let folded_mid_name = message(&[
1337 "REGISTER sip:example.net SIP/2.0",
1338 "Authorization: Digest respo",
1339 " nse=\"SPELLINGSECRET0001\"",
1340 ]);
1341 let space_before_colon = message(&[
1342 "REGISTER sip:example.net SIP/2.0",
1343 "Authorization : Digest response=\"SPELLINGSECRET0001\"",
1344 ]);
1345 let tab_before_colon = message(&[
1346 "REGISTER sip:example.net SIP/2.0",
1347 "Authorization\t: Digest response=\"SPELLINGSECRET0001\"",
1348 ]);
1349 let bare_lf = joined(
1350 &[
1351 "REGISTER sip:example.net SIP/2.0",
1352 "Authorization: Digest response=\"SPELLINGSECRET0001\"",
1353 ],
1354 "\n",
1355 );
1356 let bare_cr = joined(
1357 &[
1358 "REGISTER sip:example.net SIP/2.0",
1359 "Authorization: Digest response=\"SPELLINGSECRET0001\"",
1360 ],
1361 "\r",
1362 );
1363 let unterminated =
1364 b"REGISTER sip:example.net SIP/2.0\r\nAuthorization: Digest response=\"SPELLINGSECRET0001\"".to_vec();
1365
1366 let cases: [(&str, &[u8]); 7] = [
1367 ("folded onto a continuation line (RFC 3261 §7.3.1)", &folded),
1368 (
1369 "folded in the middle of the parameter name",
1370 &folded_mid_name,
1371 ),
1372 (
1373 "whitespace before the colon, which HCOLON permits (§25.1)",
1374 &space_before_colon,
1375 ),
1376 ("a tab before the colon", &tab_before_colon),
1377 ("bare LF, which made the whole datagram one line", &bare_lf),
1378 ("bare CR", &bare_cr),
1379 ("no trailing terminator at all", &unterminated),
1380 ];
1381
1382 for (spelling, raw) in cases {
1383 let redacted =
1384 redact(raw).unwrap_or_else(|| panic!("{spelling}: nothing was redacted at all"));
1385 let out = text(&redacted);
1386 assert!(
1387 !out.contains(SECRET),
1388 "{spelling}: the credential survived redaction: {out}"
1389 );
1390 }
1391 }
1392
1393 #[test]
1398 fn a_line_with_no_header_name_is_redacted_conservatively() {
1399 let raw = message(&[
1400 "REGISTER sip:example.net SIP/2.0",
1401 "GARBAGE WITHOUT A COLON response=\"CONSERVATIVE0004\"",
1402 ]);
1403 let out = text(&redact(&raw).expect("a nameless line is still scanned"));
1404 assert!(!out.contains("CONSERVATIVE0004"), "{out}");
1405 }
1406
1407 #[test]
1410 fn every_inline_key_on_a_crypto_line_is_redacted() {
1411 let raw = message(&[
1412 "INVITE sip:bob@example.net SIP/2.0",
1413 "Content-Length: 0",
1414 "",
1415 "v=0",
1416 "a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:FIRSTKEY0005aaaaaaaaaaaaaaaaaaaa|2^20|1;inline:SECONDKEY0006bbbbbbbbbbbbbbbbbbb|2^20|2",
1417 ]);
1418 let redacted = redact(&raw).expect("both keys are redacted");
1419 let out = text(&redacted);
1420 assert!(
1421 !out.contains("FIRSTKEY0005"),
1422 "the first key survived: {out}"
1423 );
1424 assert!(
1425 !out.contains("SECONDKEY0006"),
1426 "the second key survived: {out}"
1427 );
1428 assert!(out.contains("|2^20|1"), "the lifetime is not secret: {out}");
1429 assert!(out.contains("|2^20|2"), "{out}");
1430 assert_eq!(
1431 redacted.len(),
1432 raw.len(),
1433 "body redaction must preserve length"
1434 );
1435 }
1436
1437 #[test]
1439 fn an_opaque_scheme_has_its_whole_credential_removed() {
1440 for (scheme, secret) in [
1441 ("Bearer", "BEARERTOKEN0007"),
1442 ("bearer", "BEARERTOKEN0007"),
1443 ("Basic", "BASICSECRET0008"),
1444 ("Weird", "WEIRDTOKEN0009"),
1446 ] {
1447 let raw = message(&[
1448 "REGISTER sip:example.net SIP/2.0",
1449 &format!("Authorization: {scheme} {secret}"),
1450 ]);
1451 let out =
1452 text(&redact(&raw).unwrap_or_else(|| panic!("{scheme} was not redacted at all")));
1453 assert!(!out.contains(secret), "{scheme}: {out}");
1454 assert!(out.contains(scheme), "{scheme} should survive: {out}");
1456 }
1457 }
1458
1459 #[test]
1461 fn a_digest_header_is_not_mistaken_for_an_opaque_credential() {
1462 let raw = message(&[
1463 "REGISTER sip:example.net SIP/2.0",
1464 "Authorization: Digest realm=\"example.net\", nonce=\"n\", response=\"DIGEST0010\"",
1465 ]);
1466 let out = text(&redact(&raw).expect("redacted"));
1467 assert!(!out.contains("DIGEST0010"), "{out}");
1468 assert!(out.contains("realm=\"example.net\""), "{out}");
1469 assert!(out.contains("nonce=\"n\""), "{out}");
1470 }
1471
1472 #[test]
1474 fn an_sdp_key_field_is_redacted_but_prompt_is_not() {
1475 let raw = message(&[
1476 "INVITE sip:bob@example.net SIP/2.0",
1477 "",
1478 "v=0",
1479 "k=base64:SDPKEY0011aaaa",
1480 ]);
1481 let redacted = redact(&raw).expect("a k= key is redacted");
1482 let out = text(&redacted);
1483 assert!(!out.contains("SDPKEY0011"), "{out}");
1484 assert!(out.contains("k=base64:"), "the method is kept: {out}");
1485 assert_eq!(redacted.len(), raw.len(), "length preserved");
1486
1487 let prompt = message(&["INVITE sip:bob@example.net SIP/2.0", "", "v=0", "k=prompt"]);
1488 assert!(
1489 redact(&prompt).is_none(),
1490 "k=prompt carries no key, so there is nothing to rewrite"
1491 );
1492 }
1493
1494 #[test]
1497 fn a_credential_nested_in_a_body_is_redacted() {
1498 let raw = message(&[
1499 "INVITE sip:bob@example.net SIP/2.0",
1500 "Content-Type: message/sipfrag",
1501 "",
1502 "REGISTER sip:inner SIP/2.0",
1503 "Authorization: Digest response=\"NESTEDSECRET0012\"",
1504 ]);
1505 let redacted = redact(&raw).expect("a nested credential is redacted");
1506 let out = text(&redacted);
1507 assert!(!out.contains("NESTEDSECRET0012"), "{out}");
1508 assert_eq!(
1509 redacted.len(),
1510 raw.len(),
1511 "a body stays the length Content-Length claims"
1512 );
1513 }
1514
1515 #[test]
1518 fn an_escaped_quote_inside_a_value_does_not_end_it() {
1519 let raw = message(&[
1520 "REGISTER sip:example.net SIP/2.0",
1521 "Authorization: Digest response=\"aaa\\\"TAIL0013\"",
1522 ]);
1523 let out = text(&redact(&raw).expect("redacted"));
1524 assert!(
1525 !out.contains("TAIL0013"),
1526 "the escaped tail survived: {out}"
1527 );
1528 }
1529
1530 #[test]
1535 fn a_message_with_no_credential_is_never_rewritten() {
1536 let folded = message(&[
1537 "INVITE sip:bob@example.net SIP/2.0",
1538 "From: \"Alice\"",
1539 " <sip:alice@example.net>;tag=abcd",
1540 "Subject: a response= that is not a parameter",
1541 ]);
1542 assert!(
1543 redact(&folded).is_none(),
1544 "nothing to redact must mean no copy, so a fold survives untouched"
1545 );
1546 }
1547
1548 #[test]
1556 fn no_capture_means_the_bytes_are_never_produced() {
1557 let meters = Meters::default();
1558 let produced = std::cell::Cell::new(false);
1559
1560 Capture::observe_if_capturing(
1561 None,
1562 &meters,
1563 "127.0.0.1:5060".parse().expect("valid"),
1564 "127.0.0.1:5061".parse().expect("valid"),
1565 TransportKind::Tcp,
1566 Direction::In,
1567 || {
1568 produced.set(true);
1569 Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n")
1570 },
1571 );
1572
1573 assert!(
1574 !produced.get(),
1575 "with no capture configured the message must not even be serialised"
1576 );
1577 assert_eq!(
1578 meters.snapshot().capture,
1579 crate::counters::CaptureCounts::default(),
1580 "and nothing is counted"
1581 );
1582 }
1583
1584 #[test]
1585 fn a_header_name_is_matched_without_regard_to_case() {
1586 let out = text(
1587 &redact(b"AUTHORIZATION: Digest RESPONSE=\"secret\"\r\n\r\n")
1588 .expect("header and parameter names are case-insensitive"),
1589 );
1590 assert!(!out.contains("secret"), "{out}");
1591 }
1592
1593 #[test]
1596 fn the_synthesised_ipv4_header_carries_a_valid_checksum() {
1597 let record = Record {
1598 seq: 1,
1599 at: UNIX_EPOCH,
1600 local: "192.0.2.1:5060".parse().unwrap(),
1601 peer: "192.0.2.9:5061".parse().unwrap(),
1602 transport: TransportKind::Udp,
1603 direction: Direction::Out,
1604 bytes: Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n"),
1605 redacted: false,
1606 };
1607 let packet = synthesise(&record);
1608 assert_eq!(packet[0], 0x45, "IPv4 with a twenty-byte header");
1609 assert_eq!(packet[9], IP_PROTO_UDP);
1610 assert_eq!(
1611 ones_complement(&packet[..20]),
1612 0,
1613 "a header with a correct checksum sums to zero"
1614 );
1615 assert_eq!(&packet[20..22], &5060u16.to_be_bytes());
1617 assert_eq!(&packet[22..24], &5061u16.to_be_bytes());
1618 assert_eq!(&packet[28..], record.bytes.as_ref());
1619 }
1620
1621 #[test]
1622 fn a_tls_record_says_it_was_decrypted_in_process() {
1623 let record = Record {
1624 seq: 7,
1625 at: UNIX_EPOCH,
1626 local: "192.0.2.1:5061".parse().unwrap(),
1627 peer: "192.0.2.9:5061".parse().unwrap(),
1628 transport: TransportKind::Tls,
1629 direction: Direction::In,
1630 bytes: Bytes::from_static(b"SIP/2.0 200 OK\r\n\r\n"),
1631 redacted: true,
1632 };
1633 let comment = comment(&record);
1634 assert!(comment.contains("seq=7"), "{comment}");
1635 assert!(comment.contains("dir=in"), "{comment}");
1636 assert!(comment.contains("transport=TLS"), "{comment}");
1637 assert!(comment.contains("decrypted-in-process=yes"), "{comment}");
1638 assert!(comment.contains("redacted=yes"), "{comment}");
1639 }
1640
1641 #[test]
1642 fn ipv6_is_synthesised_as_ipv6() {
1643 let record = Record {
1644 seq: 1,
1645 at: UNIX_EPOCH,
1646 local: "[2001:db8::1]:5060".parse().unwrap(),
1647 peer: "[2001:db8::2]:5060".parse().unwrap(),
1648 transport: TransportKind::Udp,
1649 direction: Direction::In,
1650 bytes: Bytes::from_static(b"x"),
1651 redacted: false,
1652 };
1653 let packet = synthesise(&record);
1654 assert_eq!(packet[0] >> 4, 6, "version 6");
1655 assert_eq!(packet[6], IP_PROTO_UDP, "next header");
1656 assert_eq!(packet.len(), 40 + 8 + 1);
1657 }
1658
1659 #[test]
1662 fn hep_ipv4_udp_vector_is_byte_exact() {
1663 let record = Record {
1664 seq: 1,
1665 at: UNIX_EPOCH + std::time::Duration::new(1, 2_000),
1666 local: "192.0.2.10:5060".parse().unwrap(),
1667 peer: "198.51.100.20:5080".parse().unwrap(),
1668 transport: TransportKind::Udp,
1669 direction: Direction::Out,
1670 bytes: Bytes::from_static(b"SIP"),
1671 redacted: true,
1672 };
1673 let encoded = encode_hep(&record, 0x0102_0304).expect("encodes");
1674 let expected = [
1675 b'H', b'E', b'P', b'3', 0x00, 0x66, 0, 0, 0, 1, 0, 7, 2, 0, 0, 0, 2, 0, 7, 17, 0, 0, 0, 3, 0, 10, 192, 0, 2, 10, 0, 0, 0, 4, 0, 10, 198, 51, 100, 20, 0, 0, 0, 7, 0, 8, 0x13, 0xc4, 0, 0, 0, 8, 0, 8, 0x13, 0xd8, 0, 0, 0, 9, 0, 10, 0, 0, 0, 1, 0, 0, 0, 10, 0, 10, 0, 0, 0, 2, 0, 0, 0, 11, 0, 7, 1, 0, 0, 0, 12, 0, 10, 1, 2, 3, 4, 0, 0, 0, 15, 0, 9, b'S', b'I', b'P', ];
1688 assert_eq!(encoded, expected);
1689 }
1690
1691 #[test]
1694 fn an_unavailable_hep_sink_drops_without_disabling_local_capture() {
1695 let record = Record {
1696 seq: 1,
1697 at: UNIX_EPOCH,
1698 local: "192.0.2.10:5060".parse().unwrap(),
1699 peer: "198.51.100.20:5080".parse().unwrap(),
1700 transport: TransportKind::Udp,
1701 direction: Direction::Out,
1702 bytes: Bytes::from_static(b"OPTIONS sip:x SIP/2.0\r\n\r\n"),
1703 redacted: true,
1704 };
1705 let meters = Meters::default();
1706 let mut exporter = HepExporter {
1707 socket: None,
1708 config: HepConfig::new("127.0.0.1:9060".parse().unwrap(), 7),
1709 warned: true,
1710 };
1711 exporter.export(&record, &meters);
1712 let capture = meters.snapshot().capture;
1713 assert_eq!(capture.hep_records, 0);
1714 assert_eq!(capture.hep_dropped, 1);
1715 assert_eq!(capture.errors, 0, "HEP failure does not disable pcapng");
1716
1717 let mut pcapng = Vec::new();
1718 write_packet(&mut pcapng, &record).expect("local capture remains writable");
1719 assert!(!pcapng.is_empty());
1720 }
1721}