#![cfg(feature = "il2p")]
use yodel::ax25::{Address, UiFrame};
use yodel::il2p::{
self, ENCODED_MAX, HEADER_LEN, HEADER_PARITY_LEN, Il2pError, Il2pHeader, Il2pParity,
Il2pScrambler, MAX_BASELINE_BLOCK_DATA, PAYLOAD_MAX, SYNC_BYTES, SYNC_LEN, encode_raw,
encode_ui_frame,
};
fn addr(call: &[u8], ssid: u8) -> Address {
Address::new(call, ssid).unwrap()
}
fn encode_frame(frame: &UiFrame<'_>, parity: Il2pParity) -> ([u8; ENCODED_MAX], usize) {
let mut tx = [0u8; ENCODED_MAX];
let len = encode_ui_frame(frame, parity, &mut tx).unwrap();
(tx, len)
}
fn assert_roundtrip(tx: &[u8], parity: Il2pParity, expect: &UiFrame<'_>) {
let mut payload = [0u8; PAYLOAD_MAX];
let decoded = il2p::decode(&tx[SYNC_LEN..], parity, &mut payload).unwrap();
let back = il2p::to_ui_frame(&decoded.header, &payload[..decoded.payload_len]).unwrap();
assert_eq!(&back, expect);
}
#[test]
fn ui_frame_roundtrip_no_path() {
let frame = UiFrame::new(addr(b"APRS", 0), addr(b"N0CALL", 7), b">status report");
let (tx, len) = encode_frame(&frame, Il2pParity::Sixteen);
assert_eq!(&tx[..SYNC_LEN], &SYNC_BYTES);
assert_roundtrip(&tx[..len], Il2pParity::Sixteen, &frame);
}
#[test]
fn ui_frame_roundtrip_with_digipeater_path() {
let path = [addr(b"WIDE1", 1), addr(b"WIDE2", 2)];
let frame = UiFrame::with_path(
addr(b"APRS", 0),
addr(b"N0CALL", 9),
&path,
b"!4903.50N/07201.75W-",
)
.unwrap();
let (tx, len) = encode_frame(&frame, Il2pParity::Four);
let mut payload = [0u8; PAYLOAD_MAX];
let decoded = il2p::decode(&tx[SYNC_LEN..len], Il2pParity::Four, &mut payload).unwrap();
assert!(matches!(decoded.header, Il2pHeader::Transparent { .. }));
let back = il2p::to_ui_frame(&decoded.header, &payload[..decoded.payload_len]).unwrap();
assert_eq!(back, frame);
assert_eq!(back.path(), &path);
}
#[test]
fn ui_frame_roundtrip_empty_payload() {
let frame = UiFrame::new(addr(b"CQ", 0), addr(b"K1ABC", 0), b"");
let (tx, len) = encode_frame(&frame, Il2pParity::Two);
assert_eq!(
len,
SYNC_LEN + HEADER_LEN + HEADER_PARITY_LEN );
assert_roundtrip(&tx[..len], Il2pParity::Two, &frame);
}
#[test]
fn max_size_raw_payload_roundtrip() {
let payload: [u8; PAYLOAD_MAX] = core::array::from_fn(|i| (i % 251) as u8);
for parity in Il2pParity::ALL {
let mut tx = [0u8; ENCODED_MAX];
let len = encode_raw(&payload, parity, &mut tx).unwrap();
assert_eq!(len, il2p::encoded_len(PAYLOAD_MAX, parity));
let mut out = [0u8; PAYLOAD_MAX];
let decoded = il2p::decode(&tx[SYNC_LEN..len], parity, &mut out).unwrap();
assert_eq!(decoded.payload_len, PAYLOAD_MAX);
assert_eq!(out, payload);
assert_eq!(decoded.corrected(), 0);
}
}
#[test]
fn header_pack_unpack_roundtrips() {
let headers = [
Il2pHeader::Transparent { payload_len: 0 },
Il2pHeader::Transparent { payload_len: 1023 },
Il2pHeader::Translated {
command: true,
dest: addr(b"APRS", 0),
src: addr(b"N0CALL", 15),
pid: 0xF0,
payload_len: 512,
},
Il2pHeader::Translated {
command: false,
dest: addr(b"Q", 15),
src: addr(b"W1AW", 1),
pid: 0xCF, payload_len: 1,
},
];
for header in headers {
let packed = header.pack(true).unwrap();
assert_eq!(Il2pHeader::unpack(&packed).unwrap(), header);
}
}
#[test]
fn scrambler_is_self_inverse() {
let mut data = [0u8; 8];
Il2pScrambler::new().scramble(&mut data);
assert_ne!(data, [0u8; 8]);
Il2pScrambler::new().descramble(&mut data);
assert_eq!(data, [0u8; 8]);
}
const SPEC_U_AX25: [u8; 16] = [
0x86, 0xA2, 0x40, 0x40, 0x40, 0x40, 0x60, 0x96, 0x96, 0x68, 0x90, 0x8A, 0x94, 0xFF, 0x03, 0xF0,
];
const SPEC_U_IL2P_HEADER: [u8; 15] = [
0x6A, 0xEA, 0x9C, 0xC2, 0x01, 0x11, 0xFC, 0x14, 0x1F, 0xDA, 0x6E, 0xF2, 0x53, 0x91, 0xBD,
];
const SPEC_S_IL2P_HEADER: [u8; 15] = [
0x26, 0x57, 0x4D, 0x57, 0xF1, 0xD2, 0xA8, 0xF0, 0x6A, 0xF2, 0x7B, 0xAD, 0x23, 0xBD, 0xC0,
];
#[test]
fn spec_v06_u_frame_header_matches() {
let frame = UiFrame::new(addr(b"CQ", 0), addr(b"KK4HEJ", 15), b"");
let mut ax25 = [0u8; 64];
let ax25_len = frame.build(&mut ax25).unwrap();
let mut ours = ax25[..ax25_len].to_vec();
let mut theirs = SPEC_U_AX25.to_vec();
for buf in [&mut ours, &mut theirs] {
buf[6] &= 0x7F;
buf[13] &= 0x7F;
}
assert_eq!(
ours, theirs,
"our AX.25 serialization must match the spec sample (C bits aside)"
);
let header = Il2pHeader::Translated {
command: false,
dest: addr(b"CQ", 0),
src: addr(b"KK4HEJ", 15),
pid: 0xF0,
payload_len: 0,
};
let mut tx = [0u8; ENCODED_MAX];
let len = il2p::encode(&header, b"", Il2pParity::Two, &mut tx).unwrap();
assert_eq!(len, SYNC_LEN + HEADER_LEN + HEADER_PARITY_LEN);
assert_eq!(
&tx[SYNC_LEN..len],
&SPEC_U_IL2P_HEADER[..],
"IL2P header codeblock must match spec v0.6 example"
);
}
#[test]
fn max_fec_bit_is_set_for_sixteen_parity() {
let frame = UiFrame::new(addr(b"CQ", 0), addr(b"KK4HEJ", 15), b"");
for (parity, expect_set) in [
(Il2pParity::Two, false),
(Il2pParity::Four, false),
(Il2pParity::Six, false),
(Il2pParity::Eight, false),
(Il2pParity::Sixteen, true),
] {
assert_eq!(parity.is_max_fec(), expect_set, "{parity:?}");
let (tx, len) = encode_frame(&frame, parity);
let mut header = [0u8; HEADER_LEN];
header.copy_from_slice(&tx[SYNC_LEN..SYNC_LEN + HEADER_LEN]);
Il2pScrambler::new().descramble(&mut header);
assert_eq!(
header[0] & 0x80 != 0,
expect_set,
"{parity:?}: header FEC-level bit disagrees with the payload parity"
);
assert_eq!(len, SYNC_LEN + HEADER_LEN + HEADER_PARITY_LEN);
}
}
#[test]
fn spec_v06_u_frame_decodes() {
let mut wire = [0u8; ENCODED_MAX];
wire[..SYNC_LEN].copy_from_slice(&SYNC_BYTES);
wire[SYNC_LEN..SYNC_LEN + 15].copy_from_slice(&SPEC_U_IL2P_HEADER);
let decoded = il2p::decode(
&wire[SYNC_LEN..SYNC_LEN + 15],
Il2pParity::Sixteen,
&mut [0u8; PAYLOAD_MAX],
)
.expect("spec vector must decode");
match decoded.header {
Il2pHeader::Translated {
dest,
src,
pid,
payload_len,
..
} => {
assert_eq!(dest.callsign.as_bytes(), b"CQ");
assert_eq!(dest.ssid.value(), 0);
assert_eq!(src.callsign.as_bytes(), b"KK4HEJ");
assert_eq!(src.ssid.value(), 15);
assert_eq!(pid, 0xF0);
assert_eq!(payload_len, 0);
}
other => panic!("expected a translated UI header, got {other:?}"),
}
}
#[test]
fn spec_v06_s_frame_scrambler_matches() {
let mut header = SPEC_S_IL2P_HEADER[..HEADER_LEN].to_vec();
Il2pScrambler::new().descramble(&mut header);
let sixbit = |r: core::ops::Range<usize>| -> String {
header[r]
.iter()
.map(|b| ((b & 0x3F) + 0x20) as char)
.collect()
};
assert_eq!(sixbit(0..6), "KA2DEW", "destination callsign");
assert_eq!(sixbit(6..12), "KK4HEJ", "source callsign");
assert_eq!(header[12] >> 4, 2, "destination SSID");
assert_eq!(header[12] & 0x0F, 7, "source SSID");
assert_eq!(header[1] >> 7, 1, "header type must be translated");
assert_eq!(header[0] >> 7, 0, "FEC-level bit is clear in the vector");
}
#[test]
fn fec_corrects_up_to_t_per_block_and_fails_beyond() {
let source: [u8; 300] = core::array::from_fn(|i| (i as u8).wrapping_mul(29) ^ 0x77);
let cases: &[(usize, Il2pParity, Il2pParity)] = &[
(50, Il2pParity::Two, Il2pParity::Two),
(100, Il2pParity::Two, Il2pParity::Four),
(150, Il2pParity::Two, Il2pParity::Six),
(200, Il2pParity::Two, Il2pParity::Eight),
(300, Il2pParity::Sixteen, Il2pParity::Sixteen),
];
for &(len, requested, expect_effective) in cases {
let payload = &source[..len];
let max_fec = requested.is_max_fec();
let effective = il2p::payload_parity(len, max_fec);
assert_eq!(
effective, expect_effective,
"{len} bytes, max_fec {max_fec}"
);
let t = effective.correctable();
let nblocks = il2p::block_count_for(len, max_fec);
let small = len / nblocks;
let big = len % nblocks;
let mut tx = [0u8; ENCODED_MAX];
let wire = encode_raw(payload, requested, &mut tx).unwrap();
let hdr_end = SYNC_LEN + HEADER_LEN + HEADER_PARITY_LEN;
let block_at = |b: usize| {
let mut at = hdr_end;
for i in 0..b {
at += small + usize::from(i < big) + effective.len();
}
at
};
let mut corrupted = tx;
corrupted[SYNC_LEN + 2] ^= 0xFF;
for b in 0..nblocks {
for e in 0..t {
corrupted[block_at(b) + 3 + e] ^= 0xA5;
}
}
let mut out = [0u8; PAYLOAD_MAX];
let decoded = il2p::decode(&corrupted[SYNC_LEN..wire], requested, &mut out).unwrap();
assert_eq!(&out[..len], payload, "{len} bytes at {effective:?}");
assert_eq!(decoded.header_corrected, 1);
assert_eq!(decoded.payload_corrected, nblocks * t);
let mut broken = tx;
for e in 0..=t {
broken[block_at(0) + 3 + e] ^= 0xA5;
}
let err = il2p::decode(&broken[SYNC_LEN..wire], requested, &mut out).unwrap_err();
assert_eq!(
err,
Il2pError::BlockUncorrectable { block: 0 },
"{len} bytes"
);
}
}
#[test]
fn header_beyond_t_fails_cleanly() {
let frame = UiFrame::new(addr(b"APRS", 0), addr(b"N0CALL", 0), b"x");
let (mut tx, len) = encode_frame(&frame, Il2pParity::Sixteen);
tx[SYNC_LEN] ^= 0xFF;
tx[SYNC_LEN + 5] ^= 0xFF;
let mut out = [0u8; PAYLOAD_MAX];
let err = il2p::decode(&tx[SYNC_LEN..len], Il2pParity::Sixteen, &mut out).unwrap_err();
assert_eq!(err, Il2pError::HeaderUncorrectable);
}
#[test]
fn truncated_frame_reports_length() {
let frame = UiFrame::new(addr(b"APRS", 0), addr(b"N0CALL", 0), b"hello");
let (tx, len) = encode_frame(&frame, Il2pParity::Sixteen);
let mut out = [0u8; PAYLOAD_MAX];
let err = il2p::decode(&tx[SYNC_LEN..len - 1], Il2pParity::Sixteen, &mut out).unwrap_err();
assert!(matches!(err, Il2pError::FrameTooShort { .. }));
}
#[test]
fn oversize_payload_rejected() {
let big = [0u8; PAYLOAD_MAX + 1];
let mut tx = [0u8; ENCODED_MAX];
assert_eq!(
encode_raw(&big, Il2pParity::Sixteen, &mut tx),
Err(Il2pError::PayloadTooLong {
got: PAYLOAD_MAX + 1,
max: PAYLOAD_MAX,
})
);
}
fn sweep_source() -> [u8; PAYLOAD_MAX] {
core::array::from_fn(|i| (i as u8).wrapping_mul(89) ^ ((i >> 8) as u8) ^ 0x5A)
}
fn assert_length_case(len: usize, requested: Il2pParity, source: &[u8; PAYLOAD_MAX]) {
let payload = &source[..len];
let max_fec = requested.is_max_fec();
let needed = il2p::encoded_len(len, requested);
assert_eq!(
needed,
SYNC_LEN + HEADER_LEN + HEADER_PARITY_LEN + il2p::payload_wire_len(len, max_fec),
"{len} bytes, max_fec {max_fec}: encoded_len must agree with payload_wire_len"
);
let mut tx = [0u8; ENCODED_MAX];
assert_eq!(
encode_raw(payload, requested, &mut tx[..needed - 1]),
Err(Il2pError::BufferTooSmall {
needed,
got: needed - 1,
}),
"{len} bytes, max_fec {max_fec}"
);
let wire = encode_raw(payload, requested, &mut tx[..needed]).unwrap();
assert_eq!(
wire, needed,
"{len} bytes, max_fec {max_fec}: encode returned a length it did not write"
);
let mut out = [0u8; PAYLOAD_MAX];
let decoded = il2p::decode(&tx[SYNC_LEN..wire], requested, &mut out)
.unwrap_or_else(|e| panic!("{len} bytes, max_fec {max_fec}: decode failed: {e:?}"));
assert_eq!(decoded.payload_len, len, "{len} bytes, max_fec {max_fec}");
assert_eq!(
&out[..len],
payload,
"{len} bytes, max_fec {max_fec}: payload not recovered byte for byte"
);
assert_eq!(
decoded.corrected(),
0,
"{len} bytes, max_fec {max_fec}: clean channel must need no correction"
);
let err = il2p::decode(&tx[SYNC_LEN..wire - 1], requested, &mut out).unwrap_err();
assert!(
matches!(err, Il2pError::FrameTooShort { .. }),
"{len} bytes, max_fec {max_fec}: truncation by one gave {err:?}"
);
}
#[test]
fn payload_length_sweep_both_fec_levels() {
const MIN_CASES: usize = 2 * (PAYLOAD_MAX + 1);
let source = sweep_source();
let mut cases = 0usize;
for len in 0..=PAYLOAD_MAX {
for requested in [Il2pParity::Two, Il2pParity::Sixteen] {
assert_length_case(len, requested, &source);
cases += 1;
}
}
assert!(
cases >= MIN_CASES,
"sweep tested {cases} cases, expected at least {MIN_CASES}"
);
}
#[test]
fn baseline_block_count_bands_regression() {
const BANDS: [(usize, usize, usize); 4] =
[(240, 247, 1), (479, 494, 2), (718, 741, 3), (957, 988, 4)];
const MIN_CASES: usize = 80;
let source = sweep_source();
let mut cases = 0usize;
for (first, last, baseline_blocks) in BANDS {
for len in first..=last {
assert_eq!(
il2p::block_count_for(len, false),
baseline_blocks,
"{len}: baseline block count"
);
assert_eq!(
il2p::block_count_for(len, true),
baseline_blocks + 1,
"{len}: max-FEC block count must differ by one here"
);
assert_length_case(len, Il2pParity::Two, &source);
cases += 1;
}
assert_eq!(il2p::block_count_for(first - 1, false), baseline_blocks);
assert_eq!(il2p::block_count_for(first - 1, true), baseline_blocks);
if last < PAYLOAD_MAX {
assert_eq!(il2p::block_count_for(last + 1, false), baseline_blocks + 1);
assert_eq!(il2p::block_count_for(last + 1, true), baseline_blocks + 1);
}
}
assert_eq!(
cases, MIN_CASES,
"the four bands hold exactly {MIN_CASES} payload lengths"
);
}
#[test]
fn widest_baseline_block_is_247_data_bytes() {
let len = MAX_BASELINE_BLOCK_DATA;
assert_eq!(il2p::block_count_for(len, false), 1);
assert_eq!(il2p::payload_parity(len, false), Il2pParity::Eight);
assert_eq!(
il2p::encoded_len(len, Il2pParity::Two),
SYNC_LEN + HEADER_LEN + HEADER_PARITY_LEN + len + 8
);
assert_length_case(len, Il2pParity::Two, &sweep_source());
}