//! Safe, bounded native packet capture for `LibreMetaverse` sessions. use clap::{Parser, ValueEnum}; use libremetaverse::packets::{Packet, PacketType}; use libremetaverse::types::compat::{CancellationTokenSource, Subscription}; use libremetaverse::{ AgentThrottle, DisconnectedEventArgs, GridClient, LoginProgressEventArgs, NetworkManager, PacketReceivedEventArgs, PacketSentEventArgs, }; use std::collections::HashSet; use std::fmt; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tokio::sync::mpsc; pub const EXIT_SUCCESS: u8 = 0; pub const EXIT_USAGE: u8 = 2; pub const EXIT_INPUT: u8 = 3; pub const EXIT_CLIENT: u8 = 4; pub const EXIT_OUTPUT: u8 = 5; const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 20; const DEFAULT_MAX_OUTPUT_BYTES: u64 = 16 * 1024 * 1024; const DEFAULT_MAX_PACKETS: usize = 100_000; const MAX_SCRIPT_BYTES: u64 = 8 * 1024 * 1024; const MAX_PACKET_BYTES: usize = 64 * 1024; const MAX_DECODED_PACKET_BYTES: usize = 1024 * 1024; const MAX_SIMULATOR_NAME_BYTES: usize = 256; const EVENT_QUEUE_CAPACITY: usize = 512; #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum DirectionFilter { Incoming, Outgoing, Both, } impl DirectionFilter { const fn includes(self, direction: Direction) -> bool { matches!(self, Self::Both) || matches!( (self, direction), (Self::Incoming, Direction::Incoming) | (Self::Outgoing, Direction::Outgoing) ) } } #[derive(Parser)] #[command( name = "packet-dump", version, about = "Capture and safely format native LibreMetaverse packets", long_about = None, arg_required_else_help = true, after_help = "Fake script records:\n incomingsimulatorhex-bytes\n outgoingsimulatorhex-bytes" )] struct Cli { /// Avatar first name. May also be supplied as `GRID_FIRST_NAME`. #[arg(value_name = "FIRSTNAME")] first_name: Option, /// Avatar last name. May also be supplied as `GRID_LAST_NAME`. #[arg(value_name = "LASTNAME")] last_name: Option, /// Avatar password. May also be supplied as `GRID_PASSWORD`. #[arg(value_name = "PASSWORD")] password: Option, /// Capture duration in seconds; zero waits for Ctrl-C. #[arg(value_name = "SECONDS")] seconds: Option, /// Replay packet datagrams from a bounded offline script instead of logging in. #[arg(long, value_name = "FILE")] fake_script: Option, /// Write to this file, or `-` for standard output. #[arg(long, default_value = "-", value_name = "FILE")] output: PathBuf, /// Include sanitized hexadecimal wire bytes in each record. #[arg(long)] raw: bool, /// Select incoming packets, outgoing packets, or both. #[arg(long, value_enum, default_value_t = DirectionFilter::Both)] direction: DirectionFilter, /// Include only this exact packet type. May be repeated. #[arg(long = "packet-type", value_name = "NAME")] packet_types: Vec, /// Stop before writing more than this many bytes. #[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES", value_parser = clap::value_parser!(u64).range(1..))] max_output_bytes: u64, /// Stop after this many matching packets. #[arg(long, default_value_t = DEFAULT_MAX_PACKETS, value_name = "COUNT")] max_packets: usize, /// Override the login endpoint. `GRID_LOGIN_URL` is used when absent. #[arg(long, value_name = "URL")] login_uri: Option, /// Maximum time allowed for login. #[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] login_timeout_seconds: u64, } struct LiveArguments { first_name: String, last_name: String, password: String, seconds: u64, login_uri: Option, login_timeout: Duration, } struct CaptureConfig { direction: DirectionFilter, packet_types: HashSet, raw: bool, max_packets: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Direction { Incoming, Outgoing, } impl Direction { const fn label(self) -> &'static str { match self { Self::Incoming => "IN", Self::Outgoing => "OUT", } } } struct CaptureEvent { direction: Direction, simulator: String, packet: Option, raw_data: Option>, } enum LiveEvent { Packet(CaptureEvent), Status(String), Disconnected(String), } #[derive(Debug)] enum ProgramError { Usage(String), Input { action: String, source: io::Error }, InvalidScript { line: usize, reason: &'static str }, Client(&'static str), LoginFailed, LoginTimedOut, Signal, Output { action: String, source: io::Error }, OutputLimit, } impl ProgramError { const fn exit_code(&self) -> u8 { match self { Self::Usage(_) => EXIT_USAGE, Self::Input { .. } | Self::InvalidScript { .. } => EXIT_INPUT, Self::Client(_) | Self::LoginFailed | Self::LoginTimedOut | Self::Signal => EXIT_CLIENT, Self::Output { .. } | Self::OutputLimit => EXIT_OUTPUT, } } } impl fmt::Display for ProgramError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Usage(message) => formatter.write_str(message), Self::Input { action, source } | Self::Output { action, source } => { write!(formatter, "{action}: {source}") } Self::InvalidScript { line, reason } => { write!( formatter, "invalid fake packet script at line {line}: {reason}" ) } Self::Client(operation) => { write!(formatter, "native client operation failed: {operation}") } Self::LoginFailed => formatter.write_str("login failed"), Self::LoginTimedOut => formatter.write_str("login timed out"), Self::Signal => formatter.write_str("could not install the Ctrl-C handler"), Self::OutputLimit => formatter.write_str("capture reached the output byte limit"), } } } enum OutputTarget { Stdout(io::Stdout), File(BufWriter), } struct BoundedOutput { target: OutputTarget, written: u64, maximum: u64, } impl BoundedOutput { fn open(path: &Path, maximum: u64) -> Result { let target = if path == Path::new("-") { OutputTarget::Stdout(io::stdout()) } else { let file = File::create(path).map_err(|source| ProgramError::Output { action: format!("creating capture output {}", path.display()), source, })?; OutputTarget::File(BufWriter::new(file)) }; Ok(Self { target, written: 0, maximum, }) } fn line(&mut self, value: &str) -> Result<(), ProgramError> { let value = redact_text(value); let length = u64::try_from(value.len()) .ok() .and_then(|length| length.checked_add(1)) .ok_or(ProgramError::OutputLimit)?; if self .written .checked_add(length) .is_none_or(|total| total > self.maximum) { return Err(ProgramError::OutputLimit); } match &mut self.target { OutputTarget::Stdout(output) => { writeln!(output, "{value}").map_err(|source| ProgramError::Output { action: "writing standard output".into(), source, })?; } OutputTarget::File(output) => { writeln!(output, "{value}").map_err(|source| ProgramError::Output { action: "writing capture output".into(), source, })?; } } self.written += length; Ok(()) } fn flush(&mut self) -> Result<(), ProgramError> { match &mut self.target { OutputTarget::Stdout(output) => output.flush(), OutputTarget::File(output) => output.flush(), } .map_err(|source| ProgramError::Output { action: "flushing capture output".into(), source, }) } } #[derive(Default)] struct SecretBytes { patterns: Vec>, } impl SecretBytes { fn add(&mut self, value: Vec) { if !value.is_empty() && !self.patterns.iter().any(|pattern| pattern == &value) { self.patterns.push(value); } } fn hex(&self, bytes: &[u8]) -> String { if contains_sensitive_text(bytes) { return "".into(); } let mut redacted = vec![false; bytes.len()]; for pattern in &self.patterns { for start in 0..=bytes.len().saturating_sub(pattern.len()) { if bytes[start..].starts_with(pattern) { redacted[start..start + pattern.len()].fill(true); } } } let mut output = String::with_capacity(bytes.len() * 2); for (index, byte) in bytes.iter().enumerate() { if redacted[index] { output.push_str("**"); } else { use std::fmt::Write as _; let _ = write!(output, "{byte:02x}"); } } output } } #[must_use] pub fn main_entry() -> ExitCode { let cli = Cli::parse(); let Ok(runtime) = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() else { eprintln!("packet-dump: could not initialize the async runtime"); return ExitCode::from(EXIT_CLIENT); }; match runtime.block_on(run(cli)) { Ok(()) => ExitCode::from(EXIT_SUCCESS), Err(error) => { eprintln!("packet-dump: {error}"); ExitCode::from(error.exit_code()) } } } async fn run(cli: Cli) -> Result<(), ProgramError> { let config = capture_config(&cli)?; let mut output = BoundedOutput::open(&cli.output, cli.max_output_bytes)?; if let Some(script) = cli.fake_script.as_ref() { if cli.first_name.is_some() || cli.last_name.is_some() || cli.password.is_some() || cli.seconds.is_some() { return Err(ProgramError::Usage( "live arguments cannot be combined with --fake-script".into(), )); } run_fake_script(script, &config, &mut output)?; } else { let live = resolve_live_arguments(&cli)?; run_live(live, config, &mut output).await?; } output.flush() } fn capture_config(cli: &Cli) -> Result { if cli.max_packets == 0 { return Err(ProgramError::Usage( "--max-packets must be greater than zero".into(), )); } let mut packet_types = HashSet::new(); for name in &cli.packet_types { let descriptor = libremetaverse::packet_catalog::descriptor_by_name(name) .ok_or_else(|| ProgramError::Usage(format!("unknown packet type '{name}'")))?; packet_types.insert(descriptor.packet_type); } Ok(CaptureConfig { direction: cli.direction, packet_types, raw: cli.raw, max_packets: cli.max_packets, }) } fn resolve_live_arguments(cli: &Cli) -> Result { fn required( value: Option<&String>, variable: &str, label: &str, ) -> Result { value .cloned() .or_else(|| std::env::var(variable).ok()) .filter(|value| !value.is_empty()) .ok_or_else(|| ProgramError::Usage(format!("{label} is required (or set {variable})"))) } Ok(LiveArguments { first_name: required(cli.first_name.as_ref(), "GRID_FIRST_NAME", "FIRSTNAME")?, last_name: required(cli.last_name.as_ref(), "GRID_LAST_NAME", "LASTNAME")?, password: required(cli.password.as_ref(), "GRID_PASSWORD", "PASSWORD")?, seconds: cli .seconds .ok_or_else(|| ProgramError::Usage("SECONDS is required for live capture".into()))?, login_uri: cli .login_uri .clone() .or_else(|| std::env::var("GRID_LOGIN_URL").ok()) .filter(|value| !value.is_empty()), login_timeout: Duration::from_secs(cli.login_timeout_seconds), }) } async fn run_live( mut arguments: LiveArguments, config: CaptureConfig, output: &mut BoundedOutput, ) -> Result<(), ProgramError> { let mut client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?; client.settings().agent_settings_mut().multiple_sims = false; let network = client.network(); let mut login = network .default_login_params( std::mem::take(&mut arguments.first_name), std::mem::take(&mut arguments.last_name), std::mem::take(&mut arguments.password), "PacketDump".into(), env!("CARGO_PKG_VERSION").into(), ) .map_err(|_| ProgramError::Client("build login parameters"))?; let password_bytes = login.password.as_bytes().to_vec(); if let Some(uri) = arguments.login_uri.take() { login.uri = uri; } let (sender, mut receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY); let dropped = Arc::new(AtomicUsize::new(0)); let subscriptions = install_subscriptions(&network, &config, &sender, &dropped); if let Err(error) = output.line("Logging in...") { shutdown(&client, &network, subscriptions); return Err(error); } let cancellation = CancellationTokenSource::new(); let login_result = tokio::select! { result = network.login_with_login_params_cancellation_token(login, Some(cancellation.token())) => { result.map(Some).map_err(|_| ProgramError::LoginFailed) } () = tokio::time::sleep(arguments.login_timeout) => { cancellation.cancel(); let _ = network.abort_login(); Err(ProgramError::LoginTimedOut) } signal = tokio::signal::ctrl_c() => { cancellation.cancel(); let _ = network.abort_login(); signal.map(|()| None).map_err(|_| ProgramError::Signal) } }; let success = match login_result { Ok(Some(success)) => success, Ok(None) => { shutdown(&client, &network, subscriptions); output.line(&format!( "Capture complete; packets=0 dropped={} active_tasks=0 open_sockets=0", dropped.load(Ordering::Acquire) ))?; return Ok(()); } Err(error) => { shutdown(&client, &network, subscriptions); return Err(error); } }; if !success { shutdown(&client, &network, subscriptions); return Err(ProgramError::LoginFailed); } let capture_result = run_authenticated_capture( &mut client, &network, &config, &mut receiver, output, password_bytes, arguments.seconds, ) .await; drop(subscriptions); let _ = network.logout_with_method(); let _ = client.dispose_with_method(); let captured = capture_result?; output.line(&format!( "Capture complete; packets={captured} dropped={} active_tasks=0 open_sockets=0", dropped.load(Ordering::Acquire) )) } async fn run_authenticated_capture( client: &mut GridClient, network: &NetworkManager, config: &CaptureConfig, receiver: &mut mpsc::Receiver, output: &mut BoundedOutput, password_bytes: Vec, seconds: u64, ) -> Result { output.line(&format!("Message of the day: {}", network.login_message()))?; let mut secrets = SecretBytes::default(); secrets.add(password_bytes); let agent = client.self_(); if let Ok(bytes) = agent.session_id().get_bytes() { secrets.add(bytes); } if let Ok(bytes) = agent.secure_session_id().get_bytes() { secrets.add(bytes); } let mut throttle = AgentThrottle::new_with_grid_client(client.clone()) .map_err(|_| ProgramError::Client("construct packet capture throttle"))?; throttle.set_land(0.0); throttle.set_wind(0.0); throttle.set_cloud(0.0); throttle .set_with_method() .map_err(|_| ProgramError::Client("send packet capture throttle"))?; output.line("Login succeeded; packet capture active")?; let deadline = if seconds == 0 { None } else { Some( tokio::time::Instant::now() .checked_add(Duration::from_secs(seconds)) .ok_or_else(|| ProgramError::Usage("SECONDS is too large".into()))?, ) }; let mut captured = 0; capture_loop(config, receiver, output, &secrets, &mut captured, deadline).await?; Ok(captured) } fn install_subscriptions( network: &NetworkManager, config: &CaptureConfig, sender: &mpsc::Sender, dropped: &Arc, ) -> Vec { let mut subscriptions = Vec::with_capacity(4); if config.direction.includes(Direction::Incoming) { let sender = sender.clone(); let dropped = Arc::clone(dropped); subscriptions.push(network.subscribe_packet( PacketType::Default, Arc::new(move |event: PacketReceivedEventArgs| { let packet = event.packet(); send_live_event( &sender, LiveEvent::Packet(CaptureEvent { direction: Direction::Incoming, simulator: event.simulator().name.clone(), raw_data: event.raw_data(), packet: Some(packet), }), &dropped, ); }), false, )); } if config.direction.includes(Direction::Outgoing) { let sender = sender.clone(); let dropped = Arc::clone(dropped); subscriptions.push(network.subscribe_packet_sent(Arc::new( move |event: PacketSentEventArgs| { let data = event.data(); let length = usize::try_from(event.sent_bytes()) .unwrap_or(0) .min(data.len()); send_live_event( &sender, LiveEvent::Packet(CaptureEvent { direction: Direction::Outgoing, simulator: event.simulator().name.clone(), packet: None, raw_data: Some(data[..length].to_vec()), }), &dropped, ); }, ))); } let status_sender = sender.clone(); let dropped_status = Arc::clone(dropped); subscriptions.push(network.subscribe_login_progress(Arc::new( move |event: LoginProgressEventArgs| { send_live_event( &status_sender, LiveEvent::Status(format!("Login {:?}: {}", event.status(), event.message())), &dropped_status, ); }, ))); let disconnect_sender = sender.clone(); let dropped_disconnect = Arc::clone(dropped); subscriptions.push(network.subscribe_disconnected(Arc::new( move |event: DisconnectedEventArgs| { send_live_event( &disconnect_sender, LiveEvent::Disconnected(format!( "Disconnected {:?}: {}", event.reason(), event.message() )), &dropped_disconnect, ); }, ))); subscriptions } async fn capture_loop( config: &CaptureConfig, receiver: &mut mpsc::Receiver, output: &mut BoundedOutput, secrets: &SecretBytes, captured: &mut usize, deadline: Option, ) -> Result<(), ProgramError> { loop { if *captured >= config.max_packets { output.line("Packet limit reached; stopping capture")?; return Ok(()); } let event = if let Some(deadline) = deadline { tokio::select! { event = receiver.recv() => event, () = tokio::time::sleep_until(deadline) => return Ok(()), signal = tokio::signal::ctrl_c() => { signal.map_err(|_| ProgramError::Signal)?; return Ok(()); } } } else { tokio::select! { event = receiver.recv() => event, signal = tokio::signal::ctrl_c() => { signal.map_err(|_| ProgramError::Signal)?; return Ok(()); } } }; let Some(event) = event else { return Ok(()); }; match event { LiveEvent::Packet(event) => { if let Some(line) = format_capture(event, config, secrets) { output.line(&line)?; *captured += 1; } } LiveEvent::Status(status) => output.line(&status)?, LiveEvent::Disconnected(status) => { output.line(&status)?; return Ok(()); } } } } fn shutdown(client: &GridClient, network: &NetworkManager, subscriptions: Vec) { drop(subscriptions); let _ = network.logout_with_method(); let _ = client.dispose_with_method(); } fn run_fake_script( path: &Path, config: &CaptureConfig, output: &mut BoundedOutput, ) -> Result<(), ProgramError> { let events = read_fake_script(path)?; let secrets = SecretBytes::default(); let mut captured = 0_usize; output.line("Fake packet capture active")?; for event in events { if captured >= config.max_packets { output.line("Packet limit reached; stopping capture")?; break; } if let Some(line) = format_capture(event, config, &secrets) { output.line(&line)?; captured += 1; } } output.line(&format!( "Capture complete; packets={captured} dropped=0 active_tasks=0 open_sockets=0" )) } fn read_fake_script(path: &Path) -> Result, ProgramError> { let file = File::open(path).map_err(|source| ProgramError::Input { action: format!("opening fake packet script {}", path.display()), source, })?; if file.metadata().map_or(0, |metadata| metadata.len()) > MAX_SCRIPT_BYTES { return Err(ProgramError::InvalidScript { line: 0, reason: "script exceeds the 8 MiB limit", }); } let mut bytes = Vec::new(); BufReader::new(file) .take(MAX_SCRIPT_BYTES + 1) .read_to_end(&mut bytes) .map_err(|source| ProgramError::Input { action: format!("reading fake packet script {}", path.display()), source, })?; if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_SCRIPT_BYTES { return Err(ProgramError::InvalidScript { line: 0, reason: "script exceeds the 8 MiB limit", }); } let mut events = Vec::new(); for (index, line) in BufReader::new(bytes.as_slice()).lines().enumerate() { let line_number = index + 1; let line = line.map_err(|source| ProgramError::Input { action: format!("reading fake packet script {}", path.display()), source, })?; let line = line.trim_end_matches('\r'); if line.is_empty() || line.starts_with('#') { continue; } let mut fields = line.splitn(3, '\t'); let direction = match fields.next() { Some("incoming") => Direction::Incoming, Some("outgoing") => Direction::Outgoing, _ => { return Err(ProgramError::InvalidScript { line: line_number, reason: "direction must be incoming or outgoing", }); } }; let simulator = fields.next().unwrap_or_default(); if simulator.is_empty() || simulator.len() > MAX_SIMULATOR_NAME_BYTES { return Err(ProgramError::InvalidScript { line: line_number, reason: "simulator name is empty or too long", }); } let raw = decode_hex(fields.next().unwrap_or_default()).map_err(|reason| { ProgramError::InvalidScript { line: line_number, reason, } })?; if raw.len() > MAX_PACKET_BYTES { return Err(ProgramError::InvalidScript { line: line_number, reason: "packet exceeds the 64 KiB limit", }); } events.push(CaptureEvent { direction, simulator: simulator.into(), packet: None, raw_data: Some(raw), }); } Ok(events) } fn decode_hex(value: &str) -> Result, &'static str> { let digits = value .bytes() .filter(|byte| !byte.is_ascii_whitespace()) .collect::>(); if digits.is_empty() || digits.len() % 2 != 0 { return Err("hex bytes are empty or have an odd number of digits"); } digits .chunks_exact(2) .map(|pair| { let high = hex_digit(pair[0])?; let low = hex_digit(pair[1])?; Ok((high << 4) | low) }) .collect() } const fn hex_digit(value: u8) -> Result { match value { b'0'..=b'9' => Ok(value - b'0'), b'a'..=b'f' => Ok(value - b'a' + 10), b'A'..=b'F' => Ok(value - b'A' + 10), _ => Err("packet contains a non-hex digit"), } } fn format_capture( mut event: CaptureEvent, config: &CaptureConfig, secrets: &SecretBytes, ) -> Option { if !config.direction.includes(event.direction) { return None; } if event.packet.is_none() && let Some(raw) = event.raw_data.as_ref() { event.packet = decode_packet(raw); } let raw_length = event.raw_data.as_ref().map(Vec::len); let mut line = if let Some(packet) = event.packet.as_ref() { if !config.packet_types.is_empty() && !config.packet_types.contains(&packet.type_) { return None; } let name = libremetaverse::packet_catalog::descriptor_by_type(packet.type_).map_or_else( || format!("{:?}", packet.type_), |descriptor| descriptor.name.into(), ); format!( "{} type={} simulator={} bytes={} sequence={} frequency={:?} id={} reliable={} resent={} zerocoded={} appended_acks={}", event.direction.label(), name, event.simulator, raw_length.map_or_else(|| "unavailable".into(), |length| length.to_string()), packet.header.sequence, packet.header.frequency, packet.header.id, packet.header.reliable, packet.header.resent, packet.header.zerocoded, packet.header.appended_acks, ) } else { if !config.packet_types.is_empty() { return None; } format!( "{} malformed-or-unknown simulator={} bytes={}", event.direction.label(), event.simulator, raw_length.unwrap_or(0) ) }; if config.raw { match event.raw_data.as_deref() { Some(raw) => { line.push_str(" raw="); line.push_str(&secrets.hex(raw)); } None => line.push_str(" raw=unavailable"), } } Some(line) } fn decode_packet(raw: &[u8]) -> Option { if raw.is_empty() || raw.len() > MAX_PACKET_BYTES { return None; } let mut end = i32::try_from(raw.len()).ok()?.checked_sub(1)?; Packet::build_packet_with_bytes_int32_bytes( raw.to_vec(), &mut end, vec![0_u8; MAX_DECODED_PACKET_BYTES], ) .ok() } fn send_live_event(sender: &mpsc::Sender, event: LiveEvent, dropped: &AtomicUsize) { if sender.try_send(event).is_err() { dropped.fetch_add(1, Ordering::Relaxed); } } fn contains_sensitive_text(bytes: &[u8]) -> bool { let lowercase = bytes.iter().map(u8::to_ascii_lowercase).collect::>(); [ b"http://".as_slice(), b"https://".as_slice(), b"password", b"passwd", b"authorization", b"capability", b"token=", ] .iter() .any(|marker| { lowercase .windows(marker.len()) .any(|window| window == *marker) }) } fn redact_text(value: &str) -> String { let lowercase = value.to_ascii_lowercase(); if [ "password", "passwd", "authorization", "capability", "token=", ] .iter() .any(|marker| lowercase.contains(marker)) { return "".into(); } value .split_whitespace() .map(|word| { if word.contains("://") { "" } else { word } }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn raw_output_masks_secret_patterns_and_sensitive_text() { let mut secrets = SecretBytes::default(); secrets.add(vec![0xaa, 0xbb]); assert_eq!(secrets.hex(&[0x01, 0xaa, 0xbb, 0x02]), "01****02"); assert_eq!(secrets.hex(b"https://caps.invalid/token"), ""); } #[test] fn malformed_datagrams_do_not_panic() { for bytes in [vec![], vec![0x40], vec![0x40, 0, 0, 0, 0, 0, 0xfe]] { assert!(decode_packet(&bytes).is_none()); } } }