//! Tests for `dhcp.conf` parsing (`dhcp4r::config`). //! //! Locks that every setting is specifiable in the file, that the shipped //! `dhcp.conf` parses to the documented values, and that malformed input //! fails loudly (key + line info) instead of silently misconfiguring the //! server. Also locks that `Config::defaults()` is byte-for-byte the old //! hard-coded constants, so a missing file preserves legacy behavior. use dhcp4r::config::{parse_duid_hex, Config}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; fn defaults() -> Config { Config::defaults() } #[test] fn defaults_match_legacy_hardcoded_values() { let c = defaults(); assert_eq!(c.server_ip, Ipv4Addr::new(192, 168, 2, 1)); assert_eq!(c.ip_start, Ipv4Addr::new(192, 168, 2, 2)); assert_eq!(c.lease_num, 252); assert_eq!(c.subnet_mask, Ipv4Addr::new(255, 255, 255, 0)); assert_eq!(c.router_ip, Ipv4Addr::new(192, 168, 2, 1)); assert_eq!(c.dns_ips, vec![Ipv4Addr::new(8, 8, 8, 8)]); assert_eq!(c.broadcast_ip, Ipv4Addr::new(192, 168, 2, 255)); assert_eq!(c.lease_duration_secs, 86400); assert_eq!(c.leases_file, "leases"); assert_eq!(c.listen_addr, SocketAddr::from(([0, 0, 0, 0], 67))); // v6 off by default: missing file behaves exactly like the old server assert!(!c.enable_dhcpv6); // rapid commit off by default: legacy 4-message behavior preserved assert!(!c.enable_rapid_commit); assert_eq!( c.server_duid, vec![0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc] ); assert_eq!(c.ipv6_start, Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100)); assert_eq!(c.ipv6_lease_num, 1000); assert_eq!( c.ipv6_dns, vec![Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888)] ); assert_eq!(c.ipv6_lease_duration_secs, 86400); assert_eq!(c.leases_file_v6, "leases6"); assert_eq!( c.listen_addr_v6, SocketAddr::from((Ipv6Addr::UNSPECIFIED, 547)) ); } #[test] fn start_num_helpers() { let c = defaults(); assert_eq!(c.ip_start_num(), 0xC0A8_0202); assert_eq!(c.ip_start_num(), 3232236034); assert_eq!( c.ipv6_start_num(), u128::from(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100)) ); } #[test] fn parse_full_sample_sets_every_field() { let text = "\ server_ip = 10.0.0.1 ip_start = 10.0.1.10 lease_num = 100 subnet_mask = 255.255.0.0 router_ip = 10.0.0.254 dns_ips = 1.1.1.1, 1.0.0.1 broadcast_ip = 10.0.255.255 lease_duration_secs = 3600 leases_file = custom.leases listen_addr = 127.0.0.1:6767 enable_dhcpv6 = yes server_duid = 00:01:00:01:02:03:04:05:06:07 ipv6_start = 2001:db8::100 ipv6_lease_num = 500 ipv6_dns = 2001:db8::53, 2001:db8::54 ipv6_lease_duration_secs = 7200 leases_file_v6 = custom6.leases listen_addr_v6 = [::1]:5547 "; let c = Config::parse(text).unwrap(); assert_eq!(c.server_ip, Ipv4Addr::new(10, 0, 0, 1)); assert_eq!(c.ip_start, Ipv4Addr::new(10, 0, 1, 10)); assert_eq!(c.lease_num, 100); assert_eq!(c.subnet_mask, Ipv4Addr::new(255, 255, 0, 0)); assert_eq!(c.router_ip, Ipv4Addr::new(10, 0, 0, 254)); assert_eq!(c.dns_ips, vec![Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::new(1, 0, 0, 1)]); assert_eq!(c.broadcast_ip, Ipv4Addr::new(10, 0, 255, 255)); assert_eq!(c.lease_duration_secs, 3600); assert_eq!(c.leases_file, "custom.leases"); assert_eq!(c.listen_addr, "127.0.0.1:6767".parse::().unwrap()); assert!(c.enable_dhcpv6); assert_eq!( c.server_duid, vec![0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07] ); assert_eq!(c.ipv6_start, "2001:db8::100".parse::().unwrap()); assert_eq!(c.ipv6_lease_num, 500); assert_eq!( c.ipv6_dns, vec![ "2001:db8::53".parse::().unwrap(), "2001:db8::54".parse::().unwrap(), ] ); assert_eq!(c.ipv6_lease_duration_secs, 7200); assert_eq!(c.leases_file_v6, "custom6.leases"); assert_eq!(c.listen_addr_v6, "[::1]:5547".parse::().unwrap()); } #[test] fn comments_blanks_and_whitespace_tolerated() { let text = "# leading comment\n; semicolon comment\n\n \nserver_ip=10.1.2.3\n\tlease_num\t=\t7\n"; let c = Config::parse(text).unwrap(); assert_eq!(c.server_ip, Ipv4Addr::new(10, 1, 2, 3)); assert_eq!(c.lease_num, 7); // everything else stayed default assert_eq!(c.subnet_mask, defaults().subnet_mask); } #[test] fn keys_case_insensitive_and_duplicate_last_wins() { let c = Config::parse("SERVER_IP = 10.0.0.9\nServer_Ip = 10.0.0.10\n").unwrap(); assert_eq!(c.server_ip, Ipv4Addr::new(10, 0, 0, 10)); } #[test] fn unknown_keys_ignored_for_forward_compat() { let c = Config::parse("future_knob = 123\nserver_ip = 10.9.9.9\n").unwrap(); assert_eq!(c.server_ip, Ipv4Addr::new(10, 9, 9, 9)); } #[test] fn trailing_hash_comment_stripped_glued_hash_kept() { let c = Config::parse("lease_num = 5 # five leases\n").unwrap(); assert_eq!(c.lease_num, 5); // no whitespace before '#': kept verbatim -> invalid IPv4 -> error naming key let err = Config::parse("server_ip = 1.2.3.4#x\n").unwrap_err(); assert!(err.contains("server_ip"), "unexpected: {}", err); } #[test] fn bool_variants_accepted() { for truthy in ["true", "True", "TRUE", "yes", "YES", "on", "ON", "1"] { let c = Config::parse(&format!("enable_dhcpv6 = {}\n", truthy)).unwrap(); assert!(c.enable_dhcpv6, "{}", truthy); } for falsy in ["false", "False", "FALSE", "no", "NO", "off", "OFF", "0"] { let c = Config::parse(&format!("enable_dhcpv6 = {}\n", falsy)).unwrap(); assert!(!c.enable_dhcpv6, "{}", falsy); } for bad in ["2", "maybe", "onoff", ""] { let text = if bad.is_empty() { "enable_dhcpv6 =\n".to_string() } else { format!("enable_dhcpv6 = {}\n", bad) }; let err = Config::parse(&text).unwrap_err(); assert!(err.contains("enable_dhcpv6"), "unexpected: {}", err); } } #[test] fn malformed_lines_error_with_line_numbers() { let err = Config::parse("server_ip 10.0.0.1\n").unwrap_err(); assert!(err.contains('1'), "unexpected: {}", err); let err = Config::parse("server_ip = 10.0.0.1\n=oops\n").unwrap_err(); assert!(err.contains('2'), "unexpected: {}", err); let err = Config::parse(" = 10.0.0.1\n").unwrap_err(); assert!(err.contains("empty key"), "unexpected: {}", err); let err = Config::parse("lease_num =\n").unwrap_err(); assert!(err.contains("empty value"), "unexpected: {}", err); } #[test] fn invalid_values_name_the_key() { let cases = [ ("lease_num = abc\n", "lease_num"), ("lease_num = 4294967296\n", "lease_num"), // 2^32 ("server_ip = 999.1.1.1\n", "server_ip"), ("dns_ips = 8.8.8.8, bogus\n", "dns_ips"), ("dns_ips = 8.8.8.8,\n", "dns_ips"), // trailing comma ("ipv6_start = not-an-ip\n", "ipv6_start"), ("ipv6_dns = ::1, nope\n", "ipv6_dns"), ("ipv6_lease_num = -5\n", "ipv6_lease_num"), ("listen_addr = 0.0.0.0:notaport\n", "listen_addr"), ("listen_addr_v6 = [::]:99999\n", "listen_addr_v6"), ("server_duid = 00:03:0\n", "server_duid"), // odd digits ("server_duid = 00:zz\n", "server_duid"), // non-hex ("lease_duration_secs = 1.5\n", "lease_duration_secs"), ]; for (text, key) in cases { let err = Config::parse(text).unwrap_err(); assert!(err.contains(key), "{} should name {:?}: {}", text.trim(), key, err); } } #[test] fn duid_length_limits() { // empty and >128 bytes rejected assert!(Config::parse("server_duid = \n").is_err()); let too_long = "aa:".repeat(129) + "bb"; assert!(Config::parse(&format!("server_duid = {}\n", too_long)).is_err()); // exactly 128 bytes accepted let max = vec!["aa"; 128].join(":"); let c = Config::parse(&format!("server_duid = {}\n", max)).unwrap(); assert_eq!(c.server_duid.len(), 128); // single byte accepted let c = Config::parse("server_duid = ff\n").unwrap(); assert_eq!(c.server_duid, vec![0xff]); } #[test] fn pool_overflow_rejected() { // 255.255.255.255 + 2 as an exclusive end exceeds u32 space let err = Config::parse("ip_start = 255.255.255.255\nlease_num = 2\n").unwrap_err(); assert!(err.contains("overflows"), "unexpected: {}", err); // 255.255.255.255 + 1 is exactly the end (exclusive) — allowed assert!(Config::parse("ip_start = 255.255.255.255\nlease_num = 1\n").is_ok()); // 255.255.255.255 + 0 is exactly the end (exclusive) — allowed assert!(Config::parse("ip_start = 255.255.255.255\nlease_num = 0\n").is_ok()); // v6: max addr + 1 overflows u128 let err = Config::parse( "ipv6_start = ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\nipv6_lease_num = 1\n", ) .unwrap_err(); assert!(err.contains("overflows"), "unexpected: {}", err); } #[test] fn zero_lease_num_allowed() { let c = Config::parse("lease_num = 0\nipv6_lease_num = 0\n").unwrap(); assert_eq!(c.lease_num, 0); assert_eq!(c.ipv6_lease_num, 0); } #[test] fn load_missing_file_errors() { let err = Config::load("definitely-not-a-real-dhcp-conf-12345.conf").unwrap_err(); assert!(err.contains("cannot read"), "unexpected: {}", err); } #[test] fn shipped_dhcp_conf_parses_to_documented_values() { // Runs with CWD = package root under `cargo test`, where dhcp.conf ships. let c = Config::load("dhcp.conf").unwrap(); let d = defaults(); // v4 section mirrors the legacy constants assert_eq!(c.server_ip, d.server_ip); assert_eq!(c.ip_start, d.ip_start); assert_eq!(c.lease_num, d.lease_num); assert_eq!(c.subnet_mask, d.subnet_mask); assert_eq!(c.router_ip, d.router_ip); assert_eq!(c.dns_ips, d.dns_ips); // NTP section is enabled in the shipped file (differs from defaults) assert!(c.enable_ntp); assert_eq!(c.ntp_ips, vec![Ipv4Addr::new(192, 168, 2, 1)]); assert_eq!(c.broadcast_ip, d.broadcast_ip); assert_eq!(c.lease_duration_secs, d.lease_duration_secs); assert_eq!(c.leases_file, "leases"); assert_eq!(c.listen_addr, d.listen_addr); // v6 section is the managed range from the README table assert!(c.enable_dhcpv6); assert_eq!(c.server_duid, d.server_duid); assert_eq!(c.ipv6_start, d.ipv6_start); assert_eq!(c.ipv6_lease_num, d.ipv6_lease_num); assert_eq!(c.ipv6_dns, d.ipv6_dns); assert_eq!(c.ipv6_lease_duration_secs, d.ipv6_lease_duration_secs); assert_eq!(c.leases_file_v6, "leases6"); assert_eq!(c.listen_addr_v6, d.listen_addr_v6); // Rapid Commit section is enabled in the shipped file too. assert!(c.enable_rapid_commit); // Rapid Commit section is enabled in the shipped file too. assert!(c.enable_rapid_commit); // full-struct equality: file must not drift from any default silently let mut expected = d; expected.enable_dhcpv6 = true; expected.enable_ntp = true; expected.ntp_ips = vec![Ipv4Addr::new(192, 168, 2, 1)]; expected.enable_rapid_commit = true; assert_eq!(c, expected); } #[test] fn parse_duid_hex_forms() { assert_eq!( parse_duid_hex("00:03:00:01:02:00:5e:aa:bb:cc").unwrap(), vec![0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc] ); assert_eq!( parse_duid_hex("00-03-00-01").unwrap(), vec![0x00, 0x03, 0x00, 0x01] ); assert_eq!(parse_duid_hex("00030001").unwrap(), vec![0x00, 0x03, 0x00, 0x01]); assert_eq!(parse_duid_hex("AA BB").unwrap(), vec![0xaa, 0xbb]); assert!(parse_duid_hex("").is_err()); assert!(parse_duid_hex("0").is_err()); assert!(parse_duid_hex("zz").is_err()); } // --------------------------------------------------------------------------- // coverage gaps: socket forms, line endings, values with spaces, negatives // --------------------------------------------------------------------------- #[test] fn ipv6_bracketless_socket_fails() { // SocketAddr requires brackets around IPv6 hosts with ports. let err = Config::parse("listen_addr_v6 = ::1:547\n").unwrap_err(); assert!(err.contains("listen_addr_v6"), "unexpected: {}", err); } #[test] fn crlf_line_endings_ok() { let c = Config::parse("server_ip = 10.0.0.1\r\nlease_num = 7\r\n").unwrap(); assert_eq!(c.server_ip, Ipv4Addr::new(10, 0, 0, 1)); assert_eq!(c.lease_num, 7); } #[test] fn values_with_spaces_preserved() { let c = Config::parse("leases_file = my leases.conf\nleases_file_v6 = v6 leases.conf\n").unwrap(); assert_eq!(c.leases_file, "my leases.conf"); assert_eq!(c.leases_file_v6, "v6 leases.conf"); } #[test] fn negative_numbers_rejected() { for (text, key) in [ ("lease_num = -1\n", "lease_num"), ("lease_duration_secs = -60\n", "lease_duration_secs"), ("ipv6_lease_num = -1\n", "ipv6_lease_num"), ] { let err = Config::parse(text).unwrap_err(); assert!(err.contains(key), "{} should name {:?}: {}", text.trim(), key, err); } } #[test] fn rapid_commit_defaults_disabled() { assert!(!defaults().enable_rapid_commit); } #[test] fn rapid_commit_parses_flag() { for truthy in ["true", "yes", "on", "1", "True"] { let c = Config::parse(&format!("enable_rapid_commit = {}\n", truthy)).unwrap(); assert!(c.enable_rapid_commit, "{}", truthy); } for falsy in ["false", "no", "off", "0", "False"] { let c = Config::parse(&format!("enable_rapid_commit = {}\n", falsy)).unwrap(); assert!(!c.enable_rapid_commit, "{}", falsy); } let err = Config::parse("enable_rapid_commit = eventually\n").unwrap_err(); assert!(err.contains("enable_rapid_commit"), "unexpected: {}", err); assert_eq!( Config::parse("ENABLE_RAPID_COMMIT = YES\n").map(|c| c.enable_rapid_commit), Ok(true), "keys are case-insensitive" ); } // --------------------------------------------------------------------------- // NTP settings (option 42) // --------------------------------------------------------------------------- #[test] fn ntp_defaults_disabled_with_empty_list() { // Legacy behavior: the old hardcoded server never sent option 42. let c = defaults(); assert!(!c.enable_ntp); assert!(c.ntp_ips.is_empty()); } #[test] fn ntp_parses_flag_and_server_list() { let c = Config::parse("enable_ntp = true\nntp_ips = 192.168.2.1, 192.168.2.2\n").unwrap(); assert!(c.enable_ntp); assert_eq!(c.ntp_ips, vec![Ipv4Addr::new(192, 168, 2, 1), Ipv4Addr::new(192, 168, 2, 2)]); let c = Config::parse("ENABLE_NTP = Yes\nNTP_IPS = 10.0.0.5\n").unwrap(); assert!(c.enable_ntp); assert_eq!(c.ntp_ips, vec![Ipv4Addr::new(10, 0, 0, 5)]); let c = Config::parse("enable_ntp = off\n").unwrap(); assert!(!c.enable_ntp); } #[test] fn ntp_invalid_values_name_the_key() { let err = Config::parse("enable_ntp = sometimes\n").unwrap_err(); assert!(err.contains("enable_ntp"), "unexpected: {}", err); let err = Config::parse("ntp_ips = 8.8.8.8, bogus\n").unwrap_err(); assert!(err.contains("ntp_ips"), "unexpected: {}", err); let err = Config::parse("ntp_ips = 8.8.8.8,\n").unwrap_err(); assert!(err.contains("ntp_ips"), "unexpected: {}", err); } #[test] fn broadcast_ip_comma_list_and_extra_key() { // Single value keeps legacy shape: primary set, no extras. let c = Config::parse("broadcast_ip = 10.0.255.255\n").unwrap(); assert_eq!(c.broadcast_ip, Ipv4Addr::new(10, 0, 255, 255)); assert!(c.extra_broadcast_ips.is_empty()); assert_eq!(c.broadcasts(), vec![Ipv4Addr::new(10, 0, 255, 255)]); // Comma form from the issue: first stays primary, rest are extras. let c = Config::parse("broadcast_ip = 192.168.2.255, 255.255.255.255\n").unwrap(); assert_eq!(c.broadcast_ip, Ipv4Addr::new(192, 168, 2, 255)); assert_eq!( c.extra_broadcast_ips, vec![Ipv4Addr::new(255, 255, 255, 255)] ); assert_eq!( c.broadcasts(), vec![ Ipv4Addr::new(192, 168, 2, 255), Ipv4Addr::new(255, 255, 255, 255) ] ); // Dedicated key appends; duplicates collapse in broadcasts(). let c = Config::parse( "broadcast_ip = 192.168.2.255\nextra_broadcast_ips = 255.255.255.255, 192.168.2.255\n", ) .unwrap(); assert_eq!( c.broadcasts(), vec![ Ipv4Addr::new(192, 168, 2, 255), Ipv4Addr::new(255, 255, 255, 255) ] ); // Invalid entries fail loudly naming the key (no silent misroute). // (Note: 255.255.255.0 is a *valid* address and parses fine.) let err = Config::parse("broadcast_ip = 192.168.2.255, bogus\n").unwrap_err(); assert!(err.contains("broadcast_ip"), "unexpected: {}", err); let err = Config::parse("extra_broadcast_ips = bogus\n").unwrap_err(); assert!(err.contains("extra_broadcast_ips"), "unexpected: {}", err); // Defaults carry no extras (legacy single-destination behavior). assert!(defaults().extra_broadcast_ips.is_empty()); assert_eq!( defaults().broadcasts(), vec![Ipv4Addr::new(192, 168, 2, 255)] ); }