Files
RustyDHCP/tests/test_dhcp_standards.rs
T

714 lines
30 KiB
Rust

//! DHCP standards accuracy suite, part 2.
//!
//! Covers RFC 2131 / RFC 2132 / RFC 951 areas NOT covered by
//! `test_dhcp_rfc.rs`, so refactors cannot silently break accuracy:
//!
//! - full option support matrix (typed vs transparent passthrough)
//! - client packet-shape rules per state (Table 1: SELECTING / INIT-REBOOT /
//! RENEWING / INFORM / DECLINE / RELEASE)
//! - relay (`giaddr`/`hops`) destination handling and `secs` retransmission
//! - lease-time edge values incl. infinite, plus T1/T2 passthrough
//! - `sname`/`file` precise split and Overload limitation
//! - BOOTP minimum-size / header-size edge cases
//!
//! Naming: `std_*` = asserts RFC-required behavior; `*_quirk` documents a
//! current deviation and asserts present behavior so it cannot change silently.
use dhcp4r::options::*;
use dhcp4r::packet::*;
use dhcp4r::server;
use std::net::{Ipv4Addr, UdpSocket};
use std::time::Duration;
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
fn unwrap_packet(r: Result<Packet, CustomErr<&[u8]>>) -> Packet {
match r {
Ok(p) => p,
Err(_) => panic!("expected Ok Packet"),
}
}
#[allow(clippy::too_many_arguments)]
fn make_raw(
op: u8,
hlen: u8,
hops: u8,
xid: u32,
secs: u16,
flags: [u8; 2],
ci: [u8; 4],
yi: [u8; 4],
si: [u8; 4],
gi: [u8; 4],
chaddr: [u8; 6],
sname_fill: u8,
file_fill: u8,
opts_after_cookie: Vec<u8>,
) -> Vec<u8> {
let mut v = vec![0u8; 236];
v[0] = op;
v[1] = 1;
v[2] = hlen;
v[3] = hops;
v[4..8].copy_from_slice(&xid.to_be_bytes());
v[8..10].copy_from_slice(&secs.to_be_bytes());
v[10] = flags[0];
v[11] = flags[1];
v[12..16].copy_from_slice(&ci);
v[16..20].copy_from_slice(&yi);
v[20..24].copy_from_slice(&si);
v[24..28].copy_from_slice(&gi);
v[28..34].copy_from_slice(&chaddr);
// chaddr pad (10) + sname (64) + file (128) with distinct fills so tests
// can prove each region is ignored/zeroed independently.
for b in v[34..44].iter_mut() {
*b = 0; // chaddr pad must stay zero in our builder
}
for b in v[44..108].iter_mut() {
*b = sname_fill;
}
for b in v[108..236].iter_mut() {
*b = file_fill;
}
v.extend_from_slice(&[99, 130, 83, 99]);
v.extend_from_slice(&opts_after_cookie);
v
}
fn simple_raw(opts: Vec<u8>) -> Vec<u8> {
make_raw(
1, 6, 0, 0x01020304, 0, [0, 0],
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
[1, 2, 3, 4, 5, 6], 0, 0, opts,
)
}
fn client_packet(xid: u32, chaddr: [u8; 6], opts: Vec<DhcpOption>) -> Packet {
Packet {
reply: false, hops: 0, xid, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr, options: opts,
}
}
fn reply_once(msg: MessageType, additional: Vec<DhcpOption>, offer: Ipv4Addr, req: Packet) -> Packet {
let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
let srv_addr = srv_sock.local_addr().unwrap();
struct H {
msg: MessageType,
add: Option<Vec<DhcpOption>>,
offer: Ipv4Addr,
}
impl server::Handler for H {
fn handle_request(&mut self, s: &server::Server, p: Packet) {
let add = self.add.take().unwrap();
let _ = s.reply(self.msg, add, self.offer, p);
}
}
let h = H { msg, add: Some(additional), offer };
std::thread::spawn(move || {
let _ = server::Server::serve(
srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), h,
);
});
std::thread::sleep(Duration::from_millis(100));
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
let mut buf = [0u8; 1500];
let mut rbuf = [0u8; 1500];
client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap();
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let (n, _) = client.recv_from(&mut rbuf).expect("no reply");
unwrap_packet(Packet::from(&rbuf[..n]))
}
// ===========================================================================
// A. RFC 2132 option support matrix — typed vs transparent passthrough
// ===========================================================================
/// RFC 2132: options 52 (Overload), 57 (Max Message Size), 58/59 (T1/T2),
/// 60 (Vendor Class), 61 (Client Identifier) have no typed variant here and
/// MUST survive as transparent `Unrecognized` (code + data preserved).
#[test]
fn std_unsupported_rfc_options_passthrough() {
let cases: Vec<(u8, Vec<u8>)> = vec![
(52, vec![1]), // Overload: file
(57, vec![0x02, 0x40]), // Max DHCP message size 576
(58, 600u32.to_be_bytes().to_vec()), // T1 renewal
(59, 1200u32.to_be_bytes().to_vec()), // T2 rebinding
(60, b"test-client".to_vec()), // Vendor class
(61, vec![1, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), // Client id (htype+chaddr)
(66, b"tftp".to_vec()), // TFTP server name
(67, b"pxelinux.0".to_vec()), // Bootfile
(77, b"user".to_vec()), // User class
(82, vec![1, 2, 3]), // Relay agent info
(93, vec![0, 0]), // Client architecture
(100, b"EST5".to_vec()), // TZ POSIX
(101, b"Europe/Berlin".to_vec()), // TZ database
(121, vec![24, 192, 168, 1, 192, 168, 1, 1]), // Classless route
];
for (code, data) in &cases {
match decode_option(&[vec![*code, data.len() as u8], data.clone()].concat()) {
Ok((rest, DhcpOption::Unrecognized(r))) => {
assert_eq!(r.code, *code);
assert_eq!(&r.data, data);
assert!(rest.is_empty());
}
_ => panic!("code {} must decode as Unrecognized", code),
}
// to_raw preserves code+data byte-for-byte
let raw = DhcpOption::Unrecognized(RawDhcpOption { code: *code, data: data.clone() }).to_raw();
assert_eq!(raw.code, *code);
assert_eq!(&raw.data, data);
}
// and they round-trip through a full Packet (no poison like Router/DNS).
// keep it small: first 4 need 3+4+6+6=19 bytes (240+19+1=260 fits).
let small: Vec<DhcpOption> = cases[..4]
.iter()
.map(|(c, d)| DhcpOption::Unrecognized(RawDhcpOption { code: *c, data: d.clone() }))
.collect();
let small_len = small.len();
let p = Packet {
reply: false, hops: 0, xid: 0x77, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [0; 6], options: small,
};
let mut buf = [0u8; 1500];
let enc = p.encode(&mut buf).to_vec();
let q = unwrap_packet(Packet::from(&enc));
assert_eq!(q.options.len(), small_len);
for (got, (code, data)) in q.options.iter().zip(cases[..4].iter()) {
match got {
DhcpOption::Unrecognized(r) => {
assert_eq!(r.code, *code);
assert_eq!(&r.data, data);
}
_ => panic!("code {} must stay Unrecognized after round-trip", code),
}
}
}
/// RFC 2132 §9.3/§9.10 vs current decoder: Router (3) / DNS (6) encoder output
/// is standards-shaped (N*4 bytes) but `decode_option` always fails, so a
/// Packet carrying them loses them. Locked here as a known non-conformance so
/// a refactor cannot silently change half the behavior.
#[test]
fn std_router_dns_encoder_shaped_decoder_drops_quirk() {
let r = DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(10, 0, 0, 1)]).to_raw();
assert_eq!(r.code, 3);
assert_eq!(r.data.len(), 8, "RFC: 2 addrs = 8 bytes");
let d = DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]).to_raw();
assert_eq!(d.code, 6);
assert_eq!(d.data.len(), 4);
// decoder side drops them
assert!(matches!(decode_option(&[3, 4, 192, 168, 1, 1]), Err(CustomErr::InvalidHlen)));
assert!(matches!(decode_option(&[6, 4, 8, 8, 8, 8]), Err(CustomErr::InvalidHlen)));
}
/// RFC 2131 §3.5 + RFC 2132 §9.10: Client Identifier (61) SHOULD be used for
/// identity when present; this stack only keys on `chaddr`. Lock that a
/// client-id option is preserved on the wire but never consulted.
#[test]
fn std_client_identifier_ignored_for_identity_quirk() {
// wire: client-id present alongside chaddr
let p = Packet {
reply: false, hops: 0, xid: 0x88, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [0xAA; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Discover),
DhcpOption::Unrecognized(RawDhcpOption { code: 61, data: vec![1, 2, 3, 4] }),
],
};
let mut buf = [0u8; 1500];
let enc = p.encode(&mut buf).to_vec();
let q = unwrap_packet(Packet::from(&enc));
// preserved transparently ...
assert!(q.options.iter().any(|o| o.code() == 61));
// ... but identity is still chaddr (option() lookup is by code; there is
// no typed ClientId branch, so callers can only see the raw bytes)
assert_eq!(q.chaddr, [0xAA; 6]);
assert!(matches!(q.option(61), Some(DhcpOption::Unrecognized(_))));
}
// ===========================================================================
// B. RFC 2131 Table 1 / §3.1 client packet-shape rules per state
// ===========================================================================
/// RFC 2131 Table 1, DHCPDISCOVER: Server Identifier MUST NOT, Requested IP
/// MUST NOT appear. Library does not enforce (correct: enforcement is the
/// app's job) but it MUST be able to express the compliant shape.
#[test]
fn std_discover_shape_no_serverid_no_requestedip() {
let p = client_packet(0xA1, [1; 6], vec![
DhcpOption::DhcpMessageType(MessageType::Discover),
DhcpOption::ParameterRequestList(vec![1, 3, 6]),
]);
assert_eq!(p.message_type(), Ok(MessageType::Discover));
assert!(p.option(SERVER_IDENTIFIER).is_none());
assert!(p.option(REQUESTED_IP_ADDRESS).is_none());
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert!(q.option(SERVER_IDENTIFIER).is_none());
assert!(q.option(REQUESTED_IP_ADDRESS).is_none());
}
/// RFC 2131 Table 1, REQUEST SELECTING (after Offer): MUST carry Server ID +
/// Requested IP, MUST NOT carry ciaddr.
#[test]
fn std_request_selecting_shape() {
let p = Packet {
reply: false, hops: 0, xid: 0xA2, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [2; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 70)),
],
};
assert_eq!(p.ciaddr, Ipv4Addr::UNSPECIFIED);
assert!(p.option(SERVER_IDENTIFIER).is_some());
assert!(p.option(REQUESTED_IP_ADDRESS).is_some());
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert_eq!(q.option(SERVER_IDENTIFIER), Some(&DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1))));
}
/// RFC 2131 Table 1, REQUEST INIT-REBOOT: Requested IP present, Server ID
/// absent, ciaddr zero (client has no address yet).
#[test]
fn std_request_init_reboot_shape() {
let p = Packet {
reply: false, hops: 0, xid: 0xA3, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [3; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Request),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 80)),
],
};
assert!(p.option(SERVER_IDENTIFIER).is_none());
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert!(q.option(SERVER_IDENTIFIER).is_none());
assert_eq!(
q.option(REQUESTED_IP_ADDRESS),
Some(&DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 80)))
);
}
/// RFC 2131 Table 1, REQUEST RENEWING (T1 unicast): ciaddr set, NO Server ID,
/// NO Requested IP.
#[test]
fn std_request_renewing_shape() {
let p = Packet {
reply: false, hops: 0, xid: 0xA4, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::new(192, 168, 1, 50), yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [4; 6],
options: vec![DhcpOption::DhcpMessageType(MessageType::Request)],
};
assert_eq!(p.ciaddr, Ipv4Addr::new(192, 168, 1, 50));
assert!(p.option(SERVER_IDENTIFIER).is_none());
assert!(p.option(REQUESTED_IP_ADDRESS).is_none());
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert_eq!(q.ciaddr, Ipv4Addr::new(192, 168, 1, 50));
}
/// RFC 2131 Table 1, DHCPINFORM: client already has an address (ciaddr set),
/// asks only for config; MUST NOT carry Requested IP.
#[test]
fn std_inform_shape_ciaddr_no_requestedip() {
let p = Packet {
reply: false, hops: 0, xid: 0xA5, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::new(192, 168, 1, 60), yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [5; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Inform),
DhcpOption::ParameterRequestList(vec![1, 15]),
],
};
assert_eq!(p.message_type(), Ok(MessageType::Inform));
assert!(p.option(REQUESTED_IP_ADDRESS).is_none());
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert_eq!(q.message_type(), Ok(MessageType::Inform));
}
/// RFC 2131 Table 1, DECLINE: Server ID + Requested IP; RELEASE: Server ID +
/// ciaddr. Both must survive encode/decode.
#[test]
fn std_decline_release_shapes() {
let decline = Packet {
reply: false, hops: 0, xid: 0xA6, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [6; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Decline),
DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)),
DhcpOption::RequestedIpAddress(Ipv4Addr::new(192, 168, 1, 70)),
],
};
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&decline.encode(&mut buf).to_vec()));
assert_eq!(q.message_type(), Ok(MessageType::Decline));
let release = Packet {
reply: false, hops: 0, xid: 0xA7, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::new(192, 168, 1, 70), yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [6; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Release),
DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 1)),
],
};
let q = unwrap_packet(Packet::from(&release.encode(&mut buf).to_vec()));
assert_eq!(q.message_type(), Ok(MessageType::Release));
assert_eq!(q.ciaddr, Ipv4Addr::new(192, 168, 1, 70));
}
// ===========================================================================
// C. Relay, secs, hops accuracy
// ===========================================================================
/// RFC 2131 §3.3: relay sets `giaddr` to its own address and bumps `hops`.
/// Decoder MUST preserve both; server reply resets `hops` and echoes `giaddr`.
#[test]
fn std_relay_fields_preserved_and_echoed() {
let raw = make_raw(
1, 6, 2, 0xBEEF, 7, [0, 0],
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [10, 9, 9, 9],
[8; 6], 0, 0, vec![53, 1, 1, 255],
);
let p = unwrap_packet(Packet::from(&raw));
assert_eq!(p.hops, 2);
assert_eq!(p.giaddr, Ipv4Addr::new(10, 9, 9, 9));
assert_eq!(p.secs, 7);
let rep = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(10, 9, 9, 50), p);
// hmm: reply_once consumes a Packet struct, not raw; rebuild relayed struct
let _ = rep;
// struct-level relay echo (no network needed for the rule itself)
let relayed = Packet {
reply: false, hops: 2, xid: 0xBEEF, secs: 7, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::new(10, 9, 9, 9),
chaddr: [8; 6],
options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)],
};
let rep2 = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(10, 9, 9, 50), relayed);
assert_eq!(rep2.hops, 0, "server MUST reset hops");
assert_eq!(rep2.giaddr, Ipv4Addr::new(10, 9, 9, 9), "server MUST echo giaddr");
}
/// RFC 2131 §4.1 deviation locked: `Server::send` chooses destination from
/// `broadcast`/`src` only — `giaddr` is echoed but NEVER used for routing.
/// A relayed request is still answered at the peer socket, not at `giaddr`.
#[test]
fn std_giaddr_never_selects_destination_quirk() {
let srv_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
let srv_addr = srv_sock.local_addr().unwrap();
struct H;
impl server::Handler for H {
fn handle_request(&mut self, s: &server::Server, p: Packet) {
assert_eq!(p.giaddr, Ipv4Addr::new(10, 99, 99, 99));
let _ = s.reply(MessageType::Offer, vec![], Ipv4Addr::new(10, 99, 99, 50), p);
}
}
std::thread::spawn(move || {
let _ = server::Server::serve(
srv_sock, Ipv4Addr::new(192, 168, 1, 1), Ipv4Addr::new(192, 168, 1, 255), H,
);
});
std::thread::sleep(Duration::from_millis(100));
let client = UdpSocket::bind("127.0.0.1:0").unwrap();
let req = Packet {
reply: false, hops: 1, xid: 0xC1, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::new(10, 99, 99, 99),
chaddr: [9; 6],
options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)],
};
let mut buf = [0u8; 1500];
client.send_to(&req.encode(&mut buf).to_vec(), srv_addr).unwrap();
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let mut rbuf = [0u8; 1500];
// arrives back at the peer socket even though giaddr points elsewhere
let (n, _) = client.recv_from(&mut rbuf).expect("reply must arrive at src, not giaddr");
let rep = unwrap_packet(Packet::from(&rbuf[..n]));
assert_eq!(rep.giaddr, Ipv4Addr::new(10, 99, 99, 99));
assert_eq!(rep.yiaddr, Ipv4Addr::new(10, 99, 99, 50));
}
/// RFC 2131 §4.2: `secs` counts seconds since the client began acquiring;
/// it is filled by the client (preserved on decode) and zeroed by the server.
#[test]
fn std_secs_client_fills_server_zeroes() {
let raw = make_raw(
1, 6, 0, 0xC2, 30, [0, 0],
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
[1; 6], 0, 0, vec![53, 1, 1, 255],
);
assert_eq!(unwrap_packet(Packet::from(&raw)).secs, 30);
let req = Packet {
reply: false, hops: 0, xid: 0xC2, secs: 30, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [1; 6],
options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)],
};
let rep = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 1, 90), req);
assert_eq!(rep.secs, 0);
}
// ===========================================================================
// D. Lease-time accuracy (RFC 2131 §3.5, RFC 2132 §9.10)
// ===========================================================================
/// RFC 2132 §9.10: lease time is uint32 seconds, big-endian; 0xFFFFFFFF =
/// infinite. Must round-trip through packets exactly.
#[test]
fn std_lease_time_edge_values_roundtrip() {
for secs in [0u32, 1, 60, 3600, 86400, 0x7FFFFFFF, u32::MAX] {
let p = Packet {
reply: true, hops: 0, xid: 0xD1, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::new(192, 168, 1, 91),
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [2; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Ack),
DhcpOption::IpAddressLeaseTime(secs),
],
};
let mut buf = [0u8; 1500];
let enc = p.encode(&mut buf).to_vec();
let q = unwrap_packet(Packet::from(&enc));
assert_eq!(
q.option(IP_ADDRESS_LEASE_TIME),
Some(&DhcpOption::IpAddressLeaseTime(secs)),
"lease {}s must survive",
secs
);
// wire is exactly big-endian uint32
let raw = DhcpOption::IpAddressLeaseTime(secs).to_raw();
assert_eq!(raw.data, secs.to_be_bytes().to_vec());
}
}
/// RFC 2132 §9.11/§9.12: T1 (renewal) / T2 (rebinding) are uint32 seconds.
/// This stack has no typed variant, so they MUST survive as `Unrecognized`
/// with 4-byte big-endian payloads (client/server still interoperate).
#[test]
fn std_t1_t2_passthrough_with_be_seconds() {
let t1: u32 = 1800;
let t2: u32 = 3150;
let p = Packet {
reply: true, hops: 0, xid: 0xD2, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::new(192, 168, 1, 92),
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [3; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Ack),
DhcpOption::Unrecognized(RawDhcpOption { code: RENEWAL_TIME_VALUE, data: t1.to_be_bytes().to_vec() }),
DhcpOption::Unrecognized(RawDhcpOption { code: REBINDING_TIME_VALUE, data: t2.to_be_bytes().to_vec() }),
],
};
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert_eq!(
q.option(RENEWAL_TIME_VALUE),
Some(&DhcpOption::Unrecognized(RawDhcpOption { code: 58, data: t1.to_be_bytes().to_vec() }))
);
assert_eq!(
q.option(REBINDING_TIME_VALUE),
Some(&DhcpOption::Unrecognized(RawDhcpOption { code: 59, data: t2.to_be_bytes().to_vec() }))
);
}
/// RFC 2131 §3.5: `yiaddr` 0.0.0.0 in a NAK means "no address granted".
#[test]
fn std_nak_yiaddr_zero_means_no_grant() {
let req = Packet {
reply: false, hops: 0, xid: 0xD3, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [4; 6],
options: vec![DhcpOption::DhcpMessageType(MessageType::Request)],
};
let rep = reply_once(MessageType::Nak, vec![], Ipv4Addr::UNSPECIFIED, req);
assert_eq!(rep.message_type(), Ok(MessageType::Nak));
assert_eq!(rep.yiaddr, Ipv4Addr::UNSPECIFIED);
}
// ===========================================================================
// E. sname/file split + Overload limitation (RFC 2131 §2, RFC 2132 §9.3)
// ===========================================================================
/// RFC 951 §3: `chaddr` field is 16 bytes (6 + 10 pad), `sname` 64, `file`
/// 128. Encoder MUST zero all 202 bytes when unused.
#[test]
fn std_chaddr_sname_file_zero_split() {
let p = client_packet(0xE1, [0xAA; 6], vec![DhcpOption::DhcpMessageType(
MessageType::Discover,
)]);
let mut buf = [0xFFu8; 1500];
let enc = p.encode(&mut buf).to_vec();
assert_eq!(&enc[28..34], &[0xAA; 6]); // chaddr bytes
assert!(enc[34..44].iter().all(|&b| b == 0)); // chaddr pad (10)
assert!(enc[44..108].iter().all(|&b| b == 0)); // sname (64)
assert!(enc[108..236].iter().all(|&b| b == 0)); // file (128)
}
/// Decoder ignores all three regions except the first 6 `chaddr` bytes.
#[test]
fn std_sname_file_regions_ignored_independently() {
let raw = make_raw(
1, 6, 0, 0xE2, 0, [0, 0],
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
[5; 6], 0x41, 0x42, vec![53, 1, 1, 255],
);
let p = unwrap_packet(Packet::from(&raw));
assert_eq!(p.chaddr, [5; 6]);
assert_eq!(p.message_type(), Ok(MessageType::Discover));
}
/// RFC 2132 §9.3 deviation: Overload (52) is decoded as opaque data and the
/// `sname`/`file` regions are NEVER scanned for options. A uniform TLV
/// hidden in `sname` must not appear in `packet.options`.
#[test]
fn std_overload_does_not_trigger_sname_parsing_quirk() {
// overload=1 ("file holds options") + a well-formed option smuggled
// into the sname region (bytes 44..): it must be ignored.
let mut raw = make_raw(
1, 6, 0, 0xE3, 0, [0, 0],
[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0],
[6; 6], 0, 0, vec![52, 1, 1, 53, 1, 2, 255],
);
// smuggle [1,4,255,255,255,0] (subnet) into sname at offset 44
raw[44] = 1;
raw[45] = 4;
raw[46..50].copy_from_slice(&[255, 255, 255, 0]);
let p = unwrap_packet(Packet::from(&raw));
// only the real options area counts: overload + msgtype
let codes: Vec<u8> = p.options.iter().map(|o| o.code()).collect();
assert_eq!(codes, vec![52, 53]);
assert!(p.option(SUBNET_MASK).is_none(), "sname TLV must be ignored");
}
// ===========================================================================
// F. BOOTP compatibility edges: sizes, chaddr field, xid randomness shape
// ===========================================================================
/// Fixed header alone (236 bytes, no cookie/options) is rejected; header +
/// cookie with only END is the smallest decodable datagram.
#[test]
fn std_minimum_decodable_sizes() {
// all-zero 236 bytes fails hlen first (hlen=0), not the cookie check
assert!(matches!(Packet::from(&[0u8; 236]), Err(CustomErr::InvalidHlen)));
// valid header (op=1, htype=1, hlen=6) but no cookie -> NomError(Tag)
let mut no_cookie = vec![0u8; 236];
no_cookie[0] = 1;
no_cookie[1] = 1;
no_cookie[2] = 6;
assert!(matches!(Packet::from(&no_cookie), Err(CustomErr::NomError(_))));
let mut smallest = vec![0u8; 236];
smallest[0] = 1;
smallest[1] = 1;
smallest[2] = 6;
smallest.extend_from_slice(&[99, 130, 83, 99, 53, 1, 1, 255]);
assert!(Packet::from(&smallest).is_ok());
assert!(matches!(Packet::from(&[0u8; 10]), Err(CustomErr::InvalidHlen)));
}
/// RFC 951: `chaddr` on the wire is 16 bytes; only the first `hlen` (6) are
/// significant. Bytes 34..44 MUST be zero and MUST NOT leak into `chaddr`.
#[test]
fn std_chaddr_field_is_16_bytes_first_6_significant() {
let mut raw = simple_raw(vec![53, 1, 1, 255]);
raw[28..34].copy_from_slice(&[10, 20, 30, 40, 50, 60]);
raw[34..44].copy_from_slice(&[99; 10]); // non-zero pad must be ignored
let p = unwrap_packet(Packet::from(&raw));
assert_eq!(p.chaddr, [10, 20, 30, 40, 50, 60]);
let mut buf = [0u8; 1500];
let enc = p.encode(&mut buf).to_vec();
assert!(enc[34..44].iter().all(|&b| b == 0), "encoder must re-zero pad");
}
/// RFC 2131 §4.2: `xid` is a random transaction id chosen by the client and
/// echoed by the server; distinct requests SHOULD use distinct ids (shape
/// test: 0, 1, MAX all survive the round-trip).
#[test]
fn std_xid_uniqueness_shape() {
for xid in [0u32, 1, 0x12345678, u32::MAX] {
let p = client_packet(xid, [7; 6], vec![DhcpOption::DhcpMessageType(
MessageType::Discover,
)]);
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert_eq!(q.xid, xid);
let rep = reply_once(MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 1, 93), p);
assert_eq!(rep.xid, xid, "server MUST echo xid");
}
}
/// RFC 2132 §2: option data length is one byte, so no single option payload
/// may exceed 255 bytes. `to_raw` + `encode` MUST NOT emit a truncated length
/// prefix (lengths used here stay small and exact; 100-byte payloads fit in
/// `to_raw` but would overflow the 300-byte packet, so the Packet-level
/// check uses 20 bytes which fits: 240 + 3 + 22 + 1 = 266).
#[test]
fn std_option_len_single_byte_exact() {
let big = vec![9u8; 100];
let raw = DhcpOption::Unrecognized(RawDhcpOption { code: 200, data: big.clone() }).to_raw();
assert_eq!(raw.data.len(), 100);
let data = vec![9u8; 20];
let p = Packet {
reply: false, hops: 0, xid: 0xF1, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [0; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Discover),
DhcpOption::Unrecognized(RawDhcpOption { code: 200, data }),
],
};
let mut buf = [0u8; 1500];
let enc = p.encode(&mut buf).to_vec();
// second option header at 243: code 200, len 20
assert_eq!(enc[243], 200);
assert_eq!(enc[244], 20);
}
/// RFC 2131 §3.1: a subnet mask, when present, SHOULD be a contiguous mask.
/// The stack performs no validation (correct layering) and MUST preserve any
/// 4-byte value untouched.
#[test]
fn std_subnet_mask_not_validated() {
for mask in [Ipv4Addr::new(255, 255, 255, 0), Ipv4Addr::new(1, 2, 3, 4)] {
let p = Packet {
reply: true, hops: 0, xid: 0xF2, secs: 0, broadcast: false,
ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED,
siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED,
chaddr: [0; 6],
options: vec![
DhcpOption::DhcpMessageType(MessageType::Ack),
DhcpOption::SubnetMask(mask),
],
};
let mut buf = [0u8; 1500];
let q = unwrap_packet(Packet::from(&p.encode(&mut buf).to_vec()));
assert_eq!(q.option(SUBNET_MASK), Some(&DhcpOption::SubnetMask(mask)));
}
}