Files
MetaCrate/programs/src/packet_dump.rs
Chili Palmer f0420f6c8b
Some checks failed
Native Rust workspace compile / compile (push) Has been cancelled
Implement native PacketDump capture (#87)
2026-08-11 08:06:25 +00:00

939 lines
30 KiB
Rust

//! 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 incoming<TAB>simulator<TAB>hex-bytes\n outgoing<TAB>simulator<TAB>hex-bytes"
)]
struct Cli {
/// Avatar first name. May also be supplied as `GRID_FIRST_NAME`.
#[arg(value_name = "FIRSTNAME")]
first_name: Option<String>,
/// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
#[arg(value_name = "LASTNAME")]
last_name: Option<String>,
/// Avatar password. May also be supplied as `GRID_PASSWORD`.
#[arg(value_name = "PASSWORD")]
password: Option<String>,
/// Capture duration in seconds; zero waits for Ctrl-C.
#[arg(value_name = "SECONDS")]
seconds: Option<u64>,
/// Replay packet datagrams from a bounded offline script instead of logging in.
#[arg(long, value_name = "FILE")]
fake_script: Option<PathBuf>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
login_timeout: Duration,
}
struct CaptureConfig {
direction: DirectionFilter,
packet_types: HashSet<PacketType>,
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<Packet>,
raw_data: Option<Vec<u8>>,
}
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<File>),
}
struct BoundedOutput {
target: OutputTarget,
written: u64,
maximum: u64,
}
impl BoundedOutput {
fn open(path: &Path, maximum: u64) -> Result<Self, ProgramError> {
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<Vec<u8>>,
}
impl SecretBytes {
fn add(&mut self, value: Vec<u8>) {
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 "<redacted>".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<CaptureConfig, ProgramError> {
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<LiveArguments, ProgramError> {
fn required(
value: Option<&String>,
variable: &str,
label: &str,
) -> Result<String, ProgramError> {
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<LiveEvent>,
output: &mut BoundedOutput,
password_bytes: Vec<u8>,
seconds: u64,
) -> Result<usize, ProgramError> {
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<LiveEvent>,
dropped: &Arc<AtomicUsize>,
) -> Vec<Subscription> {
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<LiveEvent>,
output: &mut BoundedOutput,
secrets: &SecretBytes,
captured: &mut usize,
deadline: Option<tokio::time::Instant>,
) -> 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<Subscription>) {
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<Vec<CaptureEvent>, 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<Vec<u8>, &'static str> {
let digits = value
.bytes()
.filter(|byte| !byte.is_ascii_whitespace())
.collect::<Vec<_>>();
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<u8, &'static str> {
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<String> {
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<Packet> {
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<LiveEvent>, 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::<Vec<_>>();
[
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 "<redacted>".into();
}
value
.split_whitespace()
.map(|word| {
if word.contains("://") {
"<redacted-url>"
} else {
word
}
})
.collect::<Vec<_>>()
.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"), "<redacted>");
}
#[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());
}
}
}