use crate::detect::Strategy;
use clap::{Parser, ValueEnum};
use std::net::Ipv4Addr;
use std::time::Duration;
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum StrategyArg { Auto, Timestamp, Sequence }
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum FirstPacketPolicy { Honest, Drop }
#[derive(Parser, Debug)]
#[command(name = "liarsping", about = "Forge ICMP echo replies that lie about RTT.")]
pub struct Args {
#[arg(long, default_value = "0.0.0.0")]
pub bind: Ipv4Addr,
#[arg(long, value_enum, default_value_t = StrategyArg::Auto)]
pub strategy: StrategyArg,
#[arg(
long,
value_parser = parse_signed_duration,
allow_hyphen_values = true,
help = "Lie magnitude for both modes (signed duration, e.g. 50ms, -100ms). Overridden per-mode by --shave-timestamp / --shave-sequence."
)]
pub shave: Option<SignedDuration>,
#[arg(
long,
value_parser = parse_signed_duration,
conflicts_with = "shave_timestamp_percent",
allow_hyphen_values = true,
help = "Lie magnitude for timestamp mode (signed duration). Defaults to --shave if set, else 10ms."
)]
pub shave_timestamp: Option<SignedDuration>,
#[arg(
long,
value_parser = parse_signed_duration,
allow_hyphen_values = true,
help = "Lie magnitude for sequence mode (signed duration). Defaults to --shave if set, else 10ms."
)]
pub shave_sequence: Option<SignedDuration>,
#[arg(
long,
value_parser = parse_percent,
conflicts_with = "shave_timestamp",
allow_hyphen_values = true,
help = "Percent of (now - embedded) to shave in timestamp mode. f64, may be negative or >100. Conflicts with --shave-timestamp."
)]
pub shave_timestamp_percent: Option<f64>,
#[arg(long, default_value = "1s", value_parser = parse_duration)]
pub assumed_interval: Duration,
#[arg(long, value_enum, default_value_t = FirstPacketPolicy::Honest)]
pub first_packet: FirstPacketPolicy,
#[arg(short, long)]
pub verbose: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct SignedDuration {
pub negative: bool,
pub magnitude: Duration,
}
impl SignedDuration {
pub fn from_duration(d: Duration) -> Self {
Self { negative: false, magnitude: d }
}
pub fn from_micros_signed(us: i128) -> Self {
if us >= 0 {
let clamped = u64::try_from(us).unwrap_or(u64::MAX);
Self { negative: false, magnitude: Duration::from_micros(clamped) }
} else {
let abs = us.checked_neg().and_then(|v| u64::try_from(v).ok()).unwrap_or(u64::MAX);
Self { negative: true, magnitude: Duration::from_micros(abs) }
}
}
pub fn as_signed_micros(&self) -> i128 {
let m = self.magnitude.as_micros() as i128;
if self.negative { -m } else { m }
}
}
impl std::str::FromStr for SignedDuration {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(rest) = s.strip_prefix('-') {
let d = humantime::parse_duration(rest).map_err(|e| e.to_string())?;
if d.is_zero() {
Ok(Self { negative: false, magnitude: d })
} else {
Ok(Self { negative: true, magnitude: d })
}
} else {
let d = humantime::parse_duration(s).map_err(|e| e.to_string())?;
Ok(Self { negative: false, magnitude: d })
}
}
}
impl std::fmt::Display for SignedDuration {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.negative { write!(f, "-")?; }
write!(f, "{}", humantime::format_duration(self.magnitude))
}
}
fn parse_duration(s: &str) -> Result<Duration, String> {
humantime::parse_duration(s).map_err(|e| e.to_string())
}
fn parse_signed_duration(s: &str) -> Result<SignedDuration, String> {
s.parse()
}
fn parse_percent(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
if !v.is_finite() {
return Err("percentage must be finite (not NaN or infinity)".to_string());
}
Ok(v)
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum TimestampLie {
Absolute(SignedDuration),
Percent(f64),
}
#[derive(Debug, Clone)]
pub struct Config {
pub bind: Ipv4Addr,
pub forced_strategy: Option<Strategy>,
pub timestamp_lie: TimestampLie,
pub sequence_shave: SignedDuration,
pub assumed_interval: Duration,
pub first_packet: FirstPacketPolicy,
pub verbose: bool,
}
impl From<Args> for Config {
fn from(a: Args) -> Self {
let default_shave = SignedDuration::from_duration(Duration::from_millis(10));
let ts_absolute = a.shave_timestamp.or(a.shave).unwrap_or(default_shave);
let timestamp_lie = match a.shave_timestamp_percent {
Some(p) => TimestampLie::Percent(p),
None => TimestampLie::Absolute(ts_absolute),
};
let sequence_shave = a.shave_sequence.or(a.shave).unwrap_or(default_shave);
Config {
bind: a.bind,
forced_strategy: match a.strategy {
StrategyArg::Auto => None,
StrategyArg::Timestamp => Some(Strategy::Timestamp),
StrategyArg::Sequence => Some(Strategy::Sequence),
},
timestamp_lie,
sequence_shave,
assumed_interval: a.assumed_interval,
first_packet: a.first_packet,
verbose: a.verbose,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn defaults_match_spec() {
let a = Args::parse_from(["liarsping"]);
let c: Config = a.into();
assert_eq!(c.bind, Ipv4Addr::UNSPECIFIED);
assert!(c.forced_strategy.is_none());
assert_eq!(c.sequence_shave, SignedDuration::from_duration(Duration::from_millis(10)));
assert_eq!(c.assumed_interval, Duration::from_secs(1));
assert_eq!(c.first_packet, FirstPacketPolicy::Honest);
assert!(!c.verbose);
}
#[test]
fn forced_strategy_parses() {
let a = Args::parse_from(["liarsping", "--strategy", "sequence"]);
let c: Config = a.into();
assert_eq!(c.forced_strategy, Some(Strategy::Sequence));
}
#[test]
fn shave_timestamp_accepts_humantime() {
let c: Config = Args::parse_from(["liarsping", "--shave-timestamp", "750us"]).into();
match c.timestamp_lie {
TimestampLie::Absolute(s) => {
assert!(!s.negative);
assert_eq!(s.magnitude, Duration::from_micros(750));
}
other => panic!("expected Absolute, got {other:?}"),
}
}
#[test]
fn shave_timestamp_accepts_negative() {
let c: Config = Args::parse_from(["liarsping", "--shave-timestamp", "-50ms"]).into();
match c.timestamp_lie {
TimestampLie::Absolute(s) => {
assert!(s.negative);
assert_eq!(s.magnitude, Duration::from_millis(50));
}
other => panic!("expected Absolute, got {other:?}"),
}
}
#[test]
fn shave_sequence_accepts_negative() {
let c: Config = Args::parse_from(["liarsping", "--shave-sequence", "-200ms"]).into();
assert!(c.sequence_shave.negative);
assert_eq!(c.sequence_shave.magnitude, Duration::from_millis(200));
}
#[test]
fn signed_duration_parses_positive() {
let s: SignedDuration = "10ms".parse().unwrap();
assert_eq!(s, SignedDuration { negative: false, magnitude: Duration::from_millis(10) });
}
#[test]
fn signed_duration_parses_negative() {
let s: SignedDuration = "-50ms".parse().unwrap();
assert_eq!(s, SignedDuration { negative: true, magnitude: Duration::from_millis(50) });
}
#[test]
fn signed_duration_canonicalizes_minus_zero() {
let s: SignedDuration = "-0s".parse().unwrap();
assert!(!s.negative);
assert_eq!(s.magnitude, Duration::ZERO);
}
#[test]
fn signed_duration_rejects_empty() {
assert!("".parse::<SignedDuration>().is_err());
}
#[test]
fn signed_duration_rejects_bare_minus() {
assert!("-".parse::<SignedDuration>().is_err());
}
#[test]
fn signed_duration_rejects_double_minus() {
assert!("--10ms".parse::<SignedDuration>().is_err());
}
#[test]
fn signed_duration_as_signed_micros_positive() {
let s: SignedDuration = "10ms".parse().unwrap();
assert_eq!(s.as_signed_micros(), 10_000);
}
#[test]
fn signed_duration_as_signed_micros_negative() {
let s: SignedDuration = "-50ms".parse().unwrap();
assert_eq!(s.as_signed_micros(), -50_000);
}
#[test]
fn signed_duration_from_micros_signed_round_trip_negative() {
let s = SignedDuration::from_micros_signed(-12_345);
assert!(s.negative);
assert_eq!(s.magnitude, Duration::from_micros(12_345));
assert_eq!(s.as_signed_micros(), -12_345);
}
#[test]
fn signed_duration_from_micros_signed_round_trip_positive() {
let s = SignedDuration::from_micros_signed(12_345);
assert!(!s.negative);
assert_eq!(s.magnitude, Duration::from_micros(12_345));
assert_eq!(s.as_signed_micros(), 12_345);
}
#[test]
fn signed_duration_from_duration_is_non_negative() {
let s = SignedDuration::from_duration(Duration::from_millis(5));
assert!(!s.negative);
assert_eq!(s.magnitude, Duration::from_millis(5));
}
#[test]
fn shave_timestamp_missing_value_errors_cleanly() {
let r = Args::try_parse_from(["liarsping", "--shave-timestamp", "--verbose"]);
assert!(r.is_err(), "expected parse error, got {r:?}");
}
#[test]
fn shave_convenience_sets_both_when_specifics_absent() {
let c: Config = Args::parse_from(["liarsping", "--shave", "5ms"]).into();
let expected = "5ms".parse::<SignedDuration>().unwrap();
assert_eq!(c.timestamp_lie, TimestampLie::Absolute(expected));
assert_eq!(c.sequence_shave, expected);
}
#[test]
fn specific_timestamp_overrides_shave_convenience() {
let c: Config = Args::parse_from(
["liarsping", "--shave", "5ms", "--shave-timestamp", "20ms"],
).into();
assert_eq!(
c.timestamp_lie,
TimestampLie::Absolute("20ms".parse().unwrap()),
);
assert_eq!(c.sequence_shave, "5ms".parse().unwrap());
}
#[test]
fn specific_sequence_overrides_shave_convenience() {
let c: Config = Args::parse_from(
["liarsping", "--shave", "5ms", "--shave-sequence", "-30ms"],
).into();
assert_eq!(
c.timestamp_lie,
TimestampLie::Absolute("5ms".parse().unwrap()),
);
assert!(c.sequence_shave.negative);
assert_eq!(c.sequence_shave.magnitude, Duration::from_millis(30));
}
#[test]
fn bare_shave_accepts_negative() {
let c: Config = Args::parse_from(["liarsping", "--shave", "-100ms"]).into();
match c.timestamp_lie {
TimestampLie::Absolute(s) => {
assert!(s.negative);
assert_eq!(s.magnitude, Duration::from_millis(100));
}
other => panic!("expected Absolute, got {other:?}"),
}
assert!(c.sequence_shave.negative);
assert_eq!(c.sequence_shave.magnitude, Duration::from_millis(100));
}
#[test]
fn default_timestamp_lie_is_absolute_10ms() {
let c: Config = Args::parse_from(["liarsping"]).into();
match c.timestamp_lie {
TimestampLie::Absolute(s) => {
assert!(!s.negative);
assert_eq!(s.magnitude, Duration::from_millis(10));
}
other => panic!("expected Absolute(10ms), got {other:?}"),
}
}
#[test]
fn percent_flag_produces_percent_variant() {
let c: Config = Args::parse_from(["liarsping", "--shave-timestamp-percent", "50"]).into();
assert_eq!(c.timestamp_lie, TimestampLie::Percent(50.0));
assert_eq!(c.sequence_shave, "10ms".parse::<SignedDuration>().unwrap());
}
#[test]
fn shave_convenience_plus_percent_splits_modes() {
let c: Config = Args::parse_from(
["liarsping", "--shave", "5ms", "--shave-timestamp-percent", "50"],
).into();
assert_eq!(c.timestamp_lie, TimestampLie::Percent(50.0));
assert_eq!(c.sequence_shave, "5ms".parse::<SignedDuration>().unwrap());
}
#[test]
fn percent_allows_negative() {
let c: Config = Args::parse_from(["liarsping", "--shave-timestamp-percent", "-20"]).into();
assert_eq!(c.timestamp_lie, TimestampLie::Percent(-20.0));
}
#[test]
fn percent_allows_over_100() {
let c: Config = Args::parse_from(["liarsping", "--shave-timestamp-percent", "150"]).into();
assert_eq!(c.timestamp_lie, TimestampLie::Percent(150.0));
}
#[test]
fn percent_rejects_nan() {
let r = Args::try_parse_from(["liarsping", "--shave-timestamp-percent", "NaN"]);
assert!(r.is_err());
}
#[test]
fn percent_rejects_inf() {
let r = Args::try_parse_from(["liarsping", "--shave-timestamp-percent", "inf"]);
assert!(r.is_err());
}
#[test]
fn percent_rejects_negative_inf() {
let r = Args::try_parse_from(["liarsping", "--shave-timestamp-percent", "-inf"]);
assert!(r.is_err());
}
#[test]
fn shave_timestamp_and_percent_conflict() {
let r = Args::try_parse_from([
"liarsping", "--shave-timestamp", "5ms", "--shave-timestamp-percent", "50",
]);
assert!(r.is_err());
}
#[test]
fn shave_timestamp_and_percent_conflict_reverse_order() {
let r = Args::try_parse_from([
"liarsping", "--shave-timestamp-percent", "50", "--shave-timestamp", "5ms",
]);
assert!(r.is_err());
}
}