//! DHCP standards accuracy gaps, part 4. //! //! Covers RFC 2131 / 2132 / 951 corners NOT locked by //! `test_dhcp_rfc.rs` / `test_dhcp_standards.rs` / `test_dhcp_tables.rs` / //! `test_packet*.rs` / `test_server*.rs`: //! //! - exhaustive option-code decode branch matrix (typed vs passthrough) //! - exact diagnostic strings for `MessageType::from` / `message_type()` //! - encode buffer-size boundaries (240 panic / 241 minimal / 300 exact) //! - missing-END with trailing byte succeeds vs exact-consumption panics //! - option 255 (END) asymmetry: `to_raw` preserves, decode panics //! - hops edge values + reply reset, flags wire bytes, siaddr zeroing //! - NAK `yiaddr` left to caller (library does not force 0) //! - example Offer shape + T1/T2 RFC defaults when 58/59 absent //! - overload values 2/3, retransmit identity (xid/chaddr stable, secs grows) //! //! `*_quirk` tests lock current deviations so refactors cannot change them //! silently. All tests pass against the current implementation. use dhcp4r::options::*; use dhcp4r::packet::*; use dhcp4r::server; use std::net::{Ipv4Addr, UdpSocket}; use std::time::Duration; // --------------------------------------------------------------------------- // helpers (same patterns as the other RFC suites) // --------------------------------------------------------------------------- fn unwrap_packet(r: Result>) -> Packet { match r { Ok(p) => p, Err(_) => panic!("expected Ok Packet"), } } 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], opts_after_cookie: Vec, ) -> Vec { 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); v.extend_from_slice(&[99, 130, 83, 99]); v.extend_from_slice(&opts_after_cookie); v } fn client_packet(xid: u32, chaddr: [u8; 6], opts: Vec) -> 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, 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>, 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])) } // =========================================================================== // 1. Exhaustive option-code decode branch matrix // =========================================================================== /// RFC 2132: only codes 1, 12, 50, 51, 53, 54, 55, 56 decode as typed /// (3/6 always error — see quirk test below). Every other code 0..=254 must /// decode as transparent `Unrecognized`. Code 255 (END) panics — tested /// separately. Locks that adding a typed branch cannot break wire compat. #[test] fn gap_decode_branch_matrix_typed_vs_passthrough() { // (code, wire bytes, expected typed check) // typed codes with valid payloads match decode_option(&[53, 1, 1]) { Ok((_, DhcpOption::DhcpMessageType(MessageType::Discover))) => {} _ => panic!("53 must be typed"), } match decode_option(&[54, 4, 10, 0, 0, 1]) { Ok((_, DhcpOption::ServerIdentifier(ip))) => { assert_eq!(ip, Ipv4Addr::new(10, 0, 0, 1)) } _ => panic!("54 must be typed"), } match decode_option(&[50, 4, 10, 0, 0, 9]) { Ok((_, DhcpOption::RequestedIpAddress(ip))) => { assert_eq!(ip, Ipv4Addr::new(10, 0, 0, 9)) } _ => panic!("50 must be typed"), } match decode_option(&[1, 4, 255, 255, 255, 0]) { Ok((_, DhcpOption::SubnetMask(ip))) => { assert_eq!(ip, Ipv4Addr::new(255, 255, 255, 0)) } _ => panic!("1 must be typed"), } match decode_option(&[51, 4, 0, 0, 0, 10]) { Ok((_, DhcpOption::IpAddressLeaseTime(10))) => {} _ => panic!("51 must be typed"), } match decode_option(&[55, 2, 1, 3]) { Ok((_, DhcpOption::ParameterRequestList(v))) => assert_eq!(v, vec![1, 3]), _ => panic!("55 must be typed"), } match decode_option(&[12, 1, 65]) { Ok((_, DhcpOption::HostName(s))) => assert_eq!(s, "A"), _ => panic!("12 must be typed"), } match decode_option(&[56, 1, 66]) { Ok((_, DhcpOption::Message(s))) => assert_eq!(s, "B"), _ => panic!("56 must be typed"), } // 3/6 are shaped correctly but the decoder always fails (known quirk) 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) )); // everything else 0..=254 (minus typed + 3/6/255) is passthrough let typed: std::collections::HashSet = [1, 3, 6, 12, 50, 51, 53, 54, 55, 56].iter().cloned().collect(); for code in 0u8..=254 { if typed.contains(&code) { continue; } match decode_option(&[code, 1, 0xAB]) { Ok((rest, DhcpOption::Unrecognized(r))) => { assert_eq!(r.code, code, "code {}", code); assert_eq!(r.data, vec![0xAB], "code {}", code); assert!(rest.is_empty(), "code {}", code); } _ => panic!("code {} must be Unrecognized passthrough", code), } } } // =========================================================================== // 2. Exact diagnostic strings (log parsing must not break) // =========================================================================== /// `MessageType::from` error text is `Invalid DHCP Message Type: `. #[test] fn gap_message_type_from_error_string_exact() { assert_eq!( MessageType::from(9).unwrap_err(), "Invalid DHCP Message Type: 9" ); assert_eq!( MessageType::from(0).unwrap_err(), "Invalid DHCP Message Type: 0" ); assert_eq!( MessageType::from(255).unwrap_err(), "Invalid DHCP Message Type: 255" ); } /// `Packet::message_type()` distinguishes missing option vs mistyped option. #[test] fn gap_packet_message_type_error_strings_exact() { let bare = Packet { reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, chaddr: [0; 6], options: vec![], }; assert_eq!( bare.message_type().unwrap_err(), "Packet does not have MessageType option" ); let mistyped = Packet { reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, chaddr: [0; 6], options: vec![DhcpOption::Unrecognized(RawDhcpOption { code: 53, data: vec![1], })], }; assert_eq!( mistyped.message_type().unwrap_err(), "Got wrong enum code 53 for DHCP_MESSAGE_TYPE" ); } // =========================================================================== // 3. Encode buffer-size boundaries // =========================================================================== /// A 300-byte buffer is the exact maximum the encoder can emit; it must work /// and byte 299 must be END when options fill the packet. #[test] fn gap_encode_300_byte_buffer_exact_fit() { // 54-byte unrecognized + Msg(3) fills 240+3+56+1 = 300 (see truncation test) let p = Packet { reply: false, hops: 0, xid: 1, 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: vec![7u8; 54], }), ], }; let mut buf = [0u8; 300]; let enc = p.encode(&mut buf).to_vec(); assert_eq!(enc.len(), 300); assert_eq!(enc[299], 255); } /// Smallest buffer that can hold an empty-options packet is 241 bytes /// (240 header+cookie + 1 END). 241 works; 240 panics on the return slice. #[test] fn gap_encode_241_byte_buffer_minimal_ok() { let p = client_packet(1, [0; 6], vec![]); let mut buf = [0u8; 241]; let enc = p.encode(&mut buf).to_vec(); assert_eq!(enc.len(), 241); assert_eq!(enc[240], 255); } #[test] #[should_panic] fn gap_encode_240_byte_buffer_panics_quirk() { let p = client_packet(1, [0; 6], vec![]); let mut buf = [0u8; 240]; let _ = p.encode(&mut buf); } // =========================================================================== // 4. Missing-END edge: trailing byte succeeds, exact consumption panics // =========================================================================== /// No END but one trailing garbage byte: decode swallows the error, skips one /// byte via `split_at(1)`, and keeps the options decoded so far. #[test] fn gap_missing_end_with_trailing_byte_succeeds() { let raw = make_raw( 1, 6, 0, 0xABCDEF01, 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], vec![53, 1, 1, 99], // valid Discover, then garbage 99, no END ); let p = unwrap_packet(Packet::from(&raw)); assert_eq!(p.options.len(), 1); assert_eq!(p.message_type(), Ok(MessageType::Discover)); } // =========================================================================== // 5. Option 255 (END) asymmetry // =========================================================================== /// `to_raw` preserves code 255 byte-for-byte, but the decoder asserts /// `code != END`, so such a packet can be encoded yet never decoded. #[test] fn gap_unrecognized_255_to_raw_preserves() { let raw = DhcpOption::Unrecognized(RawDhcpOption { code: 255, data: vec![1], }) .to_raw(); assert_eq!(raw.code, 255); assert_eq!(raw.data, vec![1]); } #[test] #[should_panic] fn gap_unrecognized_255_packet_decode_panics_quirk() { let p = Packet { reply: false, hops: 0, xid: 1, secs: 0, broadcast: false, ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, chaddr: [0; 6], options: vec![DhcpOption::Unrecognized(RawDhcpOption { code: 255, data: vec![1], })], }; let mut buf = [0u8; 1500]; let enc = p.encode(&mut buf).to_vec(); // first option byte on the wire is the END marker -> decode panics assert_eq!(enc[240], 255); let _ = Packet::from(&enc); } // =========================================================================== // 6. Hops / flags wire bytes / siaddr / NAK yiaddr // =========================================================================== /// RFC 2131 §2: `hops` 0/16/255 survive decode; server replies reset to 0. #[test] fn gap_hops_edges_preserved_then_reset() { for hops in [0u8, 16, 255] { let raw = make_raw( 1, 6, hops, 0x1234, 0, [0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [9; 6], vec![53, 1, 1, 255], ); assert_eq!(unwrap_packet(Packet::from(&raw)).hops, hops); } let mut req = client_packet(0x99, [9; 6], vec![DhcpOption::DhcpMessageType( MessageType::Discover, )]); req.hops = 16; let rep = reply_once( MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 1, 44), req, ); assert_eq!(rep.hops, 0); } /// Broadcast flag wire bytes: true -> [128,0], false -> [0,0]. #[test] fn gap_broadcast_flag_wire_bytes() { for (bcast, expect) in [(false, [0u8, 0]), (true, [128u8, 0])] { let p = Packet { reply: true, hops: 0, xid: 1, secs: 0, broadcast: bcast, ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, siaddr: Ipv4Addr::UNSPECIFIED, giaddr: Ipv4Addr::UNSPECIFIED, chaddr: [0; 6], options: vec![], }; let mut buf = [0u8; 1500]; let enc = p.encode(&mut buf).to_vec(); assert_eq!(&enc[10..12], &expect, "broadcast={}", bcast); } } /// RFC 2131 §2: `siaddr` (next bootstrap server) is always zeroed on reply, /// even when the request carried a non-zero `siaddr`. #[test] fn gap_reply_siaddr_always_zeroed() { let req = Packet { reply: false, hops: 0, xid: 0x55, secs: 0, broadcast: false, ciaddr: Ipv4Addr::UNSPECIFIED, yiaddr: Ipv4Addr::UNSPECIFIED, siaddr: Ipv4Addr::new(5, 6, 7, 8), giaddr: Ipv4Addr::UNSPECIFIED, chaddr: [3; 6], options: vec![DhcpOption::DhcpMessageType(MessageType::Discover)], }; let rep = reply_once( MessageType::Offer, vec![], Ipv4Addr::new(192, 168, 1, 45), req, ); assert_eq!(rep.siaddr, Ipv4Addr::UNSPECIFIED); } /// Library leaves NAK `yiaddr` to the caller (example passes 0.0.0.0 per /// RFC 2131 Table 3, but a non-zero offer IP is preserved as-is). #[test] fn gap_nak_yiaddr_left_to_caller_quirk() { let req = client_packet(0x66, [4; 6], vec![DhcpOption::DhcpMessageType( MessageType::Request, )]); let rep = reply_once( MessageType::Nak, vec![], Ipv4Addr::new(1, 2, 3, 4), req, ); assert_eq!(rep.message_type(), Ok(MessageType::Nak)); assert_eq!(rep.ciaddr, Ipv4Addr::UNSPECIFIED, "NAK ciaddr forced 0"); assert_eq!( rep.yiaddr, Ipv4Addr::new(1, 2, 3, 4), "NAK yiaddr NOT forced (caller decides)" ); } // =========================================================================== // 7. Example Offer shape + T1/T2 defaults (RFC 2131 §4.4.5) // =========================================================================== /// Example server Offers carry 53/54/51/1 on decode (router/DNS are on the /// wire but dropped by the Router/DNS decode quirk) and never 58/59. #[test] fn gap_example_offer_shape_and_no_t1_t2() { let req = client_packet(0x77, [0xAA; 6], vec![DhcpOption::DhcpMessageType( MessageType::Discover, )]); let rep = reply_once( MessageType::Offer, vec![ DhcpOption::IpAddressLeaseTime(86400), DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)), DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 2, 1)]), DhcpOption::DomainNameServer(vec![Ipv4Addr::new(8, 8, 8, 8)]), ], Ipv4Addr::new(192, 168, 2, 90), req, ); let codes: Vec = rep.options.iter().map(|o| o.code()).collect(); assert_eq!(codes, vec![53, 54, 51, 1], "router/DNS dropped by decode quirk"); assert!(rep.option(RENEWAL_TIME_VALUE).is_none(), "no T1"); assert!(rep.option(REBINDING_TIME_VALUE).is_none(), "no T2"); } /// RFC 2131 §4.4.5 defaults when T1/T2 absent: T1 = 0.5*lease, /// T2 = 0.875*lease. Locks the arithmetic clients must use with our replies. #[test] fn gap_t1_t2_defaults_from_lease_time() { fn defaults(lease: u32) -> (u32, u32) { ((lease as u64 / 2) as u32, (lease as u64 * 7 / 8) as u32) } assert_eq!(defaults(86400), (43200, 75600)); assert_eq!(defaults(3600), (1800, 3150)); assert_eq!(defaults(0), (0, 0)); // reply used above carries 86400 with no T1/T2, so these apply let rep = reply_once( MessageType::Ack, vec![DhcpOption::IpAddressLeaseTime(86400)], Ipv4Addr::new(192, 168, 2, 91), client_packet(0x78, [0xBB; 6], vec![DhcpOption::DhcpMessageType( MessageType::Request, )]), ); match rep.option(IP_ADDRESS_LEASE_TIME) { Some(DhcpOption::IpAddressLeaseTime(86400)) => {} _ => panic!("lease must be 86400"), } assert!(rep.option(RENEWAL_TIME_VALUE).is_none()); assert!(rep.option(REBINDING_TIME_VALUE).is_none()); } // =========================================================================== // 8. Overload values, retransmit identity // =========================================================================== /// RFC 2132 §9.3 Overload values 1=file, 2=sname, 3=both all passthrough. #[test] fn gap_overload_values_1_2_3_passthrough() { for v in [1u8, 2, 3] { match decode_option(&[52, 1, v]) { Ok((_, DhcpOption::Unrecognized(r))) => { assert_eq!(r.code, OVERLOAD); assert_eq!(r.data, vec![v]); } _ => panic!("overload {} must passthrough", v), } } } /// RFC 2131 §4.4.1 retransmission: same xid+chaddr, growing secs = same /// transaction. Decode must preserve the triple exactly. #[test] fn gap_retransmit_identity_xid_chaddr_secs() { let ch = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]; let first = unwrap_packet(Packet::from(&make_raw( 1, 6, 0, 0x77777777, 0, [0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ch, vec![53, 1, 1, 255], ))); let retry = unwrap_packet(Packet::from(&make_raw( 1, 6, 0, 0x77777777, 5, [0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ch, vec![53, 1, 1, 255], ))); assert_eq!(first.xid, retry.xid); assert_eq!(first.chaddr, retry.chaddr); assert_eq!(first.secs, 0); assert_eq!(retry.secs, 5); assert_eq!(first.message_type(), retry.message_type()); }