use core::fmt;
use crate::ax25::frame::MAX_DIGIPEATERS;
use crate::ax25::{Address, PathHop};
pub const DEFAULT_DUPE_WINDOW_MS: u64 = 30_000;
pub const WIDE_N_MAX: u8 = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DigipeatError {
WideLimitOutOfRange {
got: u8,
},
}
impl fmt::Display for DigipeatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
DigipeatError::WideLimitOutOfRange { got } => write!(
f,
"WIDEn-N limit {got} is out of range: must be within 1..=7"
),
}
}
}
impl core::error::Error for DigipeatError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WideLimit(u8);
impl WideLimit {
pub const TWO: Self = Self(2);
pub const fn new(value: u8) -> Result<Self, DigipeatError> {
if value >= 1 && value <= WIDE_N_MAX {
Ok(Self(value))
} else {
Err(DigipeatError::WideLimitOutOfRange { got: value })
}
}
#[must_use]
pub const fn value(self) -> u8 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alias {
Exact(Address),
Wide(WideLimit),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExactAliasAction {
Keep,
Substitute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IgnoreReason {
AllHopsUsed,
NotForUs,
WideInvalid {
n: u8,
remaining: u8,
},
WideAboveLimit {
requested: u8,
max: u8,
},
PathFull,
}
impl fmt::Display for IgnoreReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
IgnoreReason::AllHopsUsed => {
write!(f, "every path hop is already used (H bit set)")
}
IgnoreReason::NotForUs => {
write!(f, "the first unused hop matches no served alias")
}
IgnoreReason::WideInvalid { n, remaining } => write!(
f,
"WIDE{n}-{remaining} is malformed: the remaining count must be within 1..={n}"
),
IgnoreReason::WideAboveLimit { requested, max } => {
write!(f, "WIDE{requested} exceeds the served maximum of WIDE{max}")
}
IgnoreReason::PathFull => write!(
f,
"callsign insertion would exceed {MAX_DIGIPEATERS} path hops"
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RelayPath {
hops: [PathHop; MAX_DIGIPEATERS],
len: usize,
}
impl RelayPath {
#[must_use]
pub fn hops(&self) -> &[PathHop] {
self.hops
.get(..self.len.min(MAX_DIGIPEATERS))
.unwrap_or(&[])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayDecision {
Relay(RelayPath),
Ignore(IgnoreReason),
}
fn parse_wide(address: &Address) -> Option<(u8, u8)> {
let call = address.callsign.as_bytes();
if call.len() != 5 || &call[..4] != b"WIDE" {
return None;
}
let digit = call[4].wrapping_sub(b'0');
if (1..=WIDE_N_MAX).contains(&digit) {
Some((digit, address.ssid.value()))
} else {
None
}
}
#[must_use]
pub fn relay_decision(
path: &[PathHop],
aliases: &[Alias],
my_call: Address,
exact_action: ExactAliasAction,
) -> RelayDecision {
if path.len() > MAX_DIGIPEATERS {
return RelayDecision::Ignore(IgnoreReason::PathFull);
}
let Some(first_unused) = path.iter().position(|hop| !hop.repeated) else {
return RelayDecision::Ignore(IgnoreReason::AllHopsUsed);
};
let hop = match path.get(first_unused) {
Some(h) => *h,
None => return RelayDecision::Ignore(IgnoreReason::NotForUs),
};
let mut out = RelayPath {
hops: [PathHop::unused(my_call); MAX_DIGIPEATERS],
len: path.len(),
};
for (slot, src) in out.hops.iter_mut().zip(path.iter()) {
*slot = *src;
}
let exact = aliases
.iter()
.any(|alias| matches!(alias, Alias::Exact(a) if *a == hop.address));
if exact {
let served = PathHop {
address: match exact_action {
ExactAliasAction::Keep => hop.address,
ExactAliasAction::Substitute => my_call,
},
repeated: true,
};
if let Some(slot) = out.hops.get_mut(first_unused) {
*slot = served;
}
return RelayDecision::Relay(out);
}
let wide_limit = aliases.iter().find_map(|alias| match alias {
Alias::Wide(limit) => Some(*limit),
Alias::Exact(_) => None,
});
if let (Some(limit), Some((n, remaining))) = (wide_limit, parse_wide(&hop.address)) {
if n > limit.value() {
return RelayDecision::Ignore(IgnoreReason::WideAboveLimit {
requested: n,
max: limit.value(),
});
}
if remaining == 0 || remaining > n {
return RelayDecision::Ignore(IgnoreReason::WideInvalid { n, remaining });
}
if remaining == 1 {
if let Some(slot) = out.hops.get_mut(first_unused) {
slot.repeated = true;
}
return RelayDecision::Relay(out);
}
if path.len() + 1 > MAX_DIGIPEATERS {
return RelayDecision::Ignore(IgnoreReason::PathFull);
}
out.len = path.len() + 1;
let mut i = out.len - 1;
while i > first_unused {
out.hops[i] = out.hops[i - 1];
i -= 1;
}
out.hops[first_unused] = PathHop {
address: my_call,
repeated: true,
};
let decremented = match Address::new(hop.address.callsign.as_bytes(), remaining - 1) {
Ok(a) => a,
Err(_) => return RelayDecision::Ignore(IgnoreReason::NotForUs),
};
if let Some(slot) = out.hops.get_mut(first_unused + 1) {
*slot = PathHop::unused(decremented);
}
return RelayDecision::Relay(out);
}
RelayDecision::Ignore(IgnoreReason::NotForUs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Freshness {
Fresh,
Duplicate,
}
impl fmt::Display for Freshness {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Freshness::Fresh => write!(f, "fresh"),
Freshness::Duplicate => write!(f, "duplicate"),
}
}
}
fn fingerprint(src: &Address, dest: &Address, info: &[u8]) -> u64 {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
let mut eat = |byte: u8| {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(PRIME);
};
for &b in src.callsign.as_bytes() {
eat(b);
}
eat(src.ssid.value());
for &b in dest.callsign.as_bytes() {
eat(b);
}
eat(dest.ssid.value());
for &b in info {
eat(b);
}
hash
}
#[derive(Debug, Clone)]
pub struct DupeRing<const N: usize> {
entries: [Option<(u64, u64)>; N],
cursor: usize,
window_ms: u64,
}
impl<const N: usize> DupeRing<N> {
#[must_use]
pub const fn new() -> Self {
Self::with_window(DEFAULT_DUPE_WINDOW_MS)
}
#[must_use]
pub const fn with_window(window_ms: u64) -> Self {
Self {
entries: [None; N],
cursor: 0,
window_ms,
}
}
pub fn check_and_insert(
&mut self,
src: &Address,
dest: &Address,
info: &[u8],
now_ms: u64,
) -> Freshness {
let fp = fingerprint(src, dest, info);
for entry in self.entries.iter_mut().flatten() {
if entry.0 == fp {
if now_ms.saturating_sub(entry.1) < self.window_ms {
return Freshness::Duplicate;
}
entry.1 = now_ms;
return Freshness::Fresh;
}
}
if let Some(slot) = self.entries.get_mut(self.cursor) {
*slot = Some((fp, now_ms));
}
self.cursor = if N == 0 { 0 } else { (self.cursor + 1) % N };
Freshness::Fresh
}
}
impl<const N: usize> Default for DupeRing<N> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn addr(call: &[u8], ssid: u8) -> Address {
match Address::new(call, ssid) {
Ok(a) => a,
Err(e) => panic!("{e}"),
}
}
fn served() -> [Alias; 2] {
[
Alias::Exact(addr(b"N0CALL", 1)),
Alias::Wide(WideLimit::TWO),
]
}
fn relay(decision: RelayDecision) -> RelayPath {
match decision {
RelayDecision::Relay(path) => path,
RelayDecision::Ignore(reason) => panic!("expected relay, got ignore: {reason}"),
}
}
#[test]
fn exact_alias_sets_h_bit() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(my), PathHop::unused(addr(b"WIDE2", 1))];
let out = relay(relay_decision(&path, &served(), my, ExactAliasAction::Keep));
assert_eq!(
out.hops(),
&[
PathHop {
address: my,
repeated: true
},
PathHop::unused(addr(b"WIDE2", 1)),
]
);
}
#[test]
fn exact_alias_substitution_inserts_my_call() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(addr(b"N0CALL", 2))];
let aliases = [Alias::Exact(addr(b"N0CALL", 2))];
let out = relay(relay_decision(
&path,
&aliases,
my,
ExactAliasAction::Substitute,
));
assert_eq!(
out.hops(),
&[PathHop {
address: my,
repeated: true
}]
);
}
#[test]
fn wide2_1_consumed_in_place() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(addr(b"WIDE2", 1))];
let out = relay(relay_decision(&path, &served(), my, ExactAliasAction::Keep));
assert_eq!(
out.hops(),
&[PathHop {
address: addr(b"WIDE2", 1),
repeated: true
}]
);
}
#[test]
fn wide2_2_decrements_and_inserts() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(addr(b"WIDE2", 2))];
let out = relay(relay_decision(&path, &served(), my, ExactAliasAction::Keep));
assert_eq!(
out.hops(),
&[
PathHop {
address: my,
repeated: true
},
PathHop::unused(addr(b"WIDE2", 1)),
]
);
}
#[test]
fn wide1_1_consumed() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(addr(b"WIDE1", 1))];
let out = relay(relay_decision(&path, &served(), my, ExactAliasAction::Keep));
assert_eq!(
out.hops(),
&[PathHop {
address: addr(b"WIDE1", 1),
repeated: true
}]
);
}
#[test]
fn skips_used_hops_to_first_unused() {
let my = addr(b"N0CALL", 1);
let path = [
PathHop {
address: addr(b"K1ABC", 0),
repeated: true,
},
PathHop::unused(addr(b"WIDE2", 1)),
];
let out = relay(relay_decision(&path, &served(), my, ExactAliasAction::Keep));
assert!(out.hops().iter().all(|h| h.repeated));
assert_eq!(out.hops()[0].address, addr(b"K1ABC", 0));
}
#[test]
fn fully_used_path_never_relayed() {
let my = addr(b"N0CALL", 1);
let path = [
PathHop {
address: my,
repeated: true,
},
PathHop {
address: addr(b"WIDE2", 1),
repeated: true,
},
];
assert_eq!(
relay_decision(&path, &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::AllHopsUsed)
);
assert_eq!(
relay_decision(&[], &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::AllHopsUsed)
);
}
#[test]
fn non_matching_first_hop_ignored() {
let my = addr(b"N0CALL", 1);
let path = [
PathHop::unused(addr(b"K1ABC", 0)),
PathHop::unused(addr(b"WIDE2", 1)),
];
assert_eq!(
relay_decision(&path, &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::NotForUs)
);
}
#[test]
fn wide_n_zero_refused() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(addr(b"WIDE2", 0))];
assert_eq!(
relay_decision(&path, &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::WideInvalid { n: 2, remaining: 0 })
);
}
#[test]
fn wide_remaining_above_class_refused() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(addr(b"WIDE1", 2))];
assert_eq!(
relay_decision(&path, &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::WideInvalid { n: 1, remaining: 2 })
);
}
#[test]
fn wide_above_limit_refused() {
let my = addr(b"N0CALL", 1);
let path = [PathHop::unused(addr(b"WIDE3", 3))];
assert_eq!(
relay_decision(&path, &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::WideAboveLimit {
requested: 3,
max: 2
})
);
}
#[test]
fn insertion_refused_when_path_full() {
let my = addr(b"N0CALL", 1);
let mut path = [PathHop {
address: addr(b"K1ABC", 0),
repeated: true,
}; MAX_DIGIPEATERS];
path[MAX_DIGIPEATERS - 1] = PathHop::unused(addr(b"WIDE2", 2));
assert_eq!(
relay_decision(&path, &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::PathFull)
);
}
#[test]
fn non_wide_callsigns_are_not_pattern_matched() {
let my = addr(b"N0CALL", 1);
for call in [&b"WIDE"[..], b"WIDE8", b"WIDE0", b"WIDER1", b"WIDES"] {
if let Ok(a) = Address::new(call, 1) {
assert_eq!(
parse_wide(&a),
None,
"{}",
core::str::from_utf8(call).unwrap()
);
let path = [PathHop::unused(a)];
assert_eq!(
relay_decision(&path, &served(), my, ExactAliasAction::Keep),
RelayDecision::Ignore(IgnoreReason::NotForUs)
);
}
}
}
#[test]
fn dupe_ring_suppresses_within_window() {
let src = addr(b"N0CALL", 1);
let dest = addr(b"APRS", 0);
let mut ring: DupeRing<4> = DupeRing::new();
assert_eq!(
ring.check_and_insert(&src, &dest, b">a", 0),
Freshness::Fresh
);
assert_eq!(
ring.check_and_insert(&src, &dest, b">a", 29_999),
Freshness::Duplicate
);
assert_eq!(
ring.check_and_insert(&src, &dest, b">b", 1),
Freshness::Fresh
);
}
#[test]
fn dupe_ring_admits_after_expiry() {
let src = addr(b"N0CALL", 1);
let dest = addr(b"APRS", 0);
let mut ring: DupeRing<4> = DupeRing::with_window(10_000);
assert_eq!(
ring.check_and_insert(&src, &dest, b">a", 0),
Freshness::Fresh
);
assert_eq!(
ring.check_and_insert(&src, &dest, b">a", 10_000),
Freshness::Fresh
);
assert_eq!(
ring.check_and_insert(&src, &dest, b">a", 15_000),
Freshness::Duplicate
);
}
#[test]
fn dupe_ring_evicts_oldest_at_capacity() {
let dest = addr(b"APRS", 0);
let mut ring: DupeRing<2> = DupeRing::new();
let a = addr(b"N0CALL", 1);
let b = addr(b"N1CALL", 1);
let c = addr(b"N2CALL", 1);
assert_eq!(ring.check_and_insert(&a, &dest, b">x", 0), Freshness::Fresh);
assert_eq!(ring.check_and_insert(&b, &dest, b">x", 1), Freshness::Fresh);
assert_eq!(ring.check_and_insert(&c, &dest, b">x", 2), Freshness::Fresh);
assert_eq!(ring.check_and_insert(&a, &dest, b">x", 3), Freshness::Fresh);
assert_eq!(
ring.check_and_insert(&c, &dest, b">x", 4),
Freshness::Duplicate
);
}
}