├── .gitignore ├── dub.sdl ├── source └── dhcptest │ ├── formats │ ├── package.d │ ├── types.d │ ├── formatting.d │ ├── parsing.d │ └── tests.d │ ├── network.d │ ├── dhcptest.d │ ├── packets.d │ └── options.d ├── .github └── workflows │ └── test.yml ├── dhcptest.rc └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.o 2 | *.obj 3 | *.exe 4 | *.ilk 5 | *.pdb 6 | *.res 7 | *.a 8 | *.lib 9 | 10 | .dub 11 | /dhcptest 12 | /dhcptest-test-* 13 | -------------------------------------------------------------------------------- /dub.sdl: -------------------------------------------------------------------------------- 1 | name "dhcptest" 2 | license "BSL-1.0" 3 | 4 | configuration "application" { 5 | targetType "executable" 6 | mainSourceFile "source/dhcptest/dhcptest.d" 7 | } 8 | 9 | configuration "library" { 10 | targetType "library" 11 | excludedSourceFiles "source/dhcptest/dhcptest.d" 12 | } 13 | -------------------------------------------------------------------------------- /source/dhcptest/formats/package.d: -------------------------------------------------------------------------------- 1 | module dhcptest.formats; 2 | 3 | // Re-export all public APIs from submodules 4 | public import dhcptest.formats.types; 5 | public import dhcptest.formats.parsing; 6 | public import dhcptest.formats.formatting; 7 | 8 | // Note: tests module is not imported here as it only contains unit tests 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | os: [ubuntu-24.04, windows-2025] 8 | runs-on: ${{ matrix.os }} 9 | steps: 10 | - uses: actions/checkout@v5 11 | - name: Install D compiler 12 | uses: dlang-community/setup-dlang@v2 13 | with: 14 | compiler: dmd-2.100.0 15 | - name: Build 16 | run: dub build 17 | - name: Upload binary 18 | if: ${{ matrix.os == 'windows-2025' }} 19 | uses: actions/upload-artifact@v5 20 | with: 21 | name: dhcptest-${{ matrix.os }} 22 | path: dhcptest.exe 23 | -------------------------------------------------------------------------------- /dhcptest.rc: -------------------------------------------------------------------------------- 1 | #define VER 0,9,0,0 2 | #define VER_STR "0.9" 3 | 4 | #include 5 | 6 | VS_VERSION_INFO VERSIONINFO 7 | FILEVERSION VER 8 | PRODUCTVERSION VER 9 | FILEFLAGS 0x0L 10 | FILEOS 0x4L 11 | FILETYPE 0x1L 12 | FILESUBTYPE 0x0L 13 | BEGIN 14 | BLOCK "StringFileInfo" 15 | BEGIN 16 | BLOCK "040904b0" 17 | BEGIN 18 | VALUE "ProductName", "dhcptest" 19 | VALUE "FileDescription", "dhcptest" 20 | VALUE "CompanyName", "Vladimir Panteleev" 21 | VALUE "LegalCopyright", "Vladimir Panteleev" 22 | VALUE "ProductVersion", VER_STR 23 | END 24 | END 25 | BLOCK "VarFileInfo" 26 | BEGIN 27 | VALUE "Translation", 0x409, 1200 28 | END 29 | END 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## dhcptest 2 | 3 | This is a DHCP test tool. It can send DHCP discover packets, and listen for DHCP replies. 4 | 5 | The tool is cross-platform, although you will need to compile it yourself for non-Windows platforms. 6 | 7 | The tool is written in the [D Programming Language](https://dlang.org/). 8 | 9 | ## Download 10 | 11 | You can download a compiled Windows executable from my website, [here](https://files.cy.md/dhcptest/). 12 | 13 | The latest development build for Windows can be downloaded from [GitHub Actions](https://github.com/CyberShadow/dhcptest/actions/workflows/test.yml?query=branch%3Amaster). 14 | 15 | ## Building 16 | 17 | With [DMD](https://dlang.org/download.html#dmd) (or another D compiler) installed, run: 18 | 19 | ``` 20 | $ dub build 21 | ``` 22 | 23 | ## Usage 24 | 25 | By default, dhcptest starts in interactive mode. 26 | It will listen for DHCP replies, and allow sending DHCP discover packets using the "d" command. 27 | Type `help` in interactive mode for more information. 28 | 29 | If you do not receive any replies, try using the `--bind` option (or `--iface` on Linux) to bind to a specific local interface. 30 | 31 | The program can also run in automatic mode if the `--query` switch is specified on the command line. 32 | 33 | An example command line to automatically send a discover packet and explicitly request option 43, 34 | wait for a reply, then print just that option: 35 | 36 | dhcptest --quiet --query --request 43 --print-only 43 37 | 38 | Options can also be specified by name: 39 | 40 | dhcptest --quiet --query \ 41 | --request "Vendor Specific Information" \ 42 | --print-only "Vendor Specific Information" 43 | 44 | Query mode will report the first reply recieved. To automatically send a discover packet and wait for 45 | all replies before the timeout, use `--wait`. For additional resilience against dropped packets on busy 46 | networks, consider using the `--tries` and `--timeout` switches: 47 | 48 | dhcptest --quiet --query --wait --tries 5 --timeout 10 49 | 50 | You can spoof the Vendor Class Identifier, or send additional DHCP options with the request packet, 51 | using the `--option` switch: 52 | 53 | dhcptest --query --option "60=Initech Groupware" 54 | 55 | Option 82 (Relay Agent Information) can be specified as follows: 56 | 57 | dhcptest --query --option "Relay Agent Information=agentCircuitID=\"foo\", agentRemoteID=\"bar\"" 58 | 59 | Run `dhcptest --help` for further details and additional command-line parameters. 60 | 61 | For a list and description of DHCP options, see [RFC 2132](https://datatracker.ietf.org/doc/html/rfc2132). 62 | 63 | ## License 64 | 65 | `dhcptest` is available under the [Boost Software License 1.0](https://www.boost.org/LICENSE_1_0.txt). 66 | 67 | ## Changelog 68 | 69 | ### dhcptest v0.9 (2023-03-31) 70 | 71 | * Add option 121 (contributed by [Andrey Baranov](https://github.com/Dronec)) 72 | * Add options 80, 100, 101, 108, 114, 116, 118, 249, and 252 (contributed by 73 | [Rob Gill](https://github.com/rrobgill) 74 | * Fix encoding/decoding options 43 and 82 75 | 76 | ### dhcptest v0.8 (2023-03-24) 77 | 78 | * Add `--iface` option for Linux 79 | * Add support for Linux raw sockets (`--raw`) 80 | * Add `--bind`, `--target`, and `--target-port` options 81 | * Add `--giaddr` option (contributed by [pcsegal](https://github.com/pcsegal)) 82 | * Improve formatting and parsing of many options 83 | 84 | ### dhcptest v0.7 (2017-08-03) 85 | 86 | * Refactor and improve option value parsing 87 | * Allow specifying all supported format types in both `--option` and 88 | `--print-only` switches 89 | * Allow specifying DHCP option types by name as well as by number 90 | * Allow overriding the request type option. E.g., you can now send 91 | 'request' (instead of 'discover') packets using: 92 | 93 | --option "DHCP Message Type=request" 94 | 95 | * Add formatting support for options 42 (Network Time Protocol 96 | Servers Option) and 82 (Relay Agent Information) 97 | * Change how timeouts are handled: 98 | * Always default to some finite timeout (not just when `--tries` 99 | and `--wait` are absent), but still allow waiting indefinitely if 100 | 0 is specified. 101 | * Increase default timeout from 10 to 60 seconds. 102 | 103 | ### dhcptest v0.6 (2017-08-02) 104 | 105 | * Add `--secs` switch 106 | * Contributed by [Darren White](https://github.com/DarrenWhite99): 107 | * Add `--wait` switch 108 | * The `--print-only` switch now understands output formatting: 109 | `--print-only "N[hex]"` will output the value as a zero padded hexadecimal string of bytes. 110 | `--print-only "N[ip]"` will output the value as an IP address. 111 | * Don't print stack trace on errors 112 | 113 | ### dhcptest v0.5 (2014-11-26) 114 | 115 | * The `--option` switch now understands hexadecimal or IPv4-dotted-quad formatting: 116 | `--option "N[hex]=XX XX XX ..."` or `--option "N[IP]=XXX.XXX.XXX.XXX"` 117 | 118 | ### dhcptest v0.4 (2014-07-21) 119 | 120 | * Add switches: `--tries`, `--timeout`, `--option` 121 | 122 | ### dhcptest v0.3 (2014-04-05) 123 | 124 | * Add switches: `--mac`, `--quiet`, `--query`, `--request`, `--print-only` 125 | * Print program messages to standard error 126 | 127 | ### dhcptest v0.2 (2014-03-25) 128 | 129 | * License under Boost Software License 1.0 130 | * Add documentation 131 | * Add `--help` switch 132 | * Add `--bind` switch to specify the interface to bind on 133 | * Print time values in human-readable form 134 | * Heuristically detect and print ASCII strings in unknown options 135 | * Add option names from RFC 2132 136 | * Add `help` and `quit` commands 137 | * Add MAC address option to `discover` command 138 | 139 | ### dhcptest v0.1 (2013-01-10) 140 | 141 | * Initial release 142 | -------------------------------------------------------------------------------- /source/dhcptest/network.d: -------------------------------------------------------------------------------- 1 | module dhcptest.network; 2 | 3 | import std.algorithm; 4 | import std.array; 5 | import std.conv; 6 | import std.datetime; 7 | import std.exception; 8 | import std.format; 9 | import std.range; 10 | import std.socket; 11 | import std.string; 12 | 13 | import dhcptest.packets; 14 | 15 | version (Windows) 16 | static if (__VERSION__ >= 2067) 17 | import core.sys.windows.winsock2 : ntohs, htons, ntohl, htonl; 18 | else 19 | import std.c.windows.winsock : ntohs, htons, ntohl, htonl; 20 | else 21 | version (Posix) 22 | import core.sys.posix.netdb : ntohs, htons, ntohl, htonl; 23 | else 24 | static assert(false, "Unsupported platform"); 25 | 26 | version (linux) 27 | { 28 | import core.sys.posix.sys.ioctl : ioctl, SIOCGIFINDEX; 29 | import core.sys.posix.net.if_ : IF_NAMESIZE; 30 | 31 | enum IFNAMSIZ = IF_NAMESIZE; 32 | extern(C) struct ifreq 33 | { 34 | char[IFNAMSIZ] ifr_name = 0; 35 | union 36 | { 37 | private ubyte[IFNAMSIZ] _zeroinit = 0; 38 | sockaddr ifr_addr; 39 | sockaddr ifr_dstaddr; 40 | sockaddr ifr_broadaddr; 41 | sockaddr ifr_netmask; 42 | sockaddr ifr_hwaddr; 43 | short ifr_flags; 44 | int ifr_ifindex; 45 | int ifr_metric; 46 | int ifr_mtu; 47 | // ifmap ifr_map; 48 | char[IFNAMSIZ] ifr_slave; 49 | char[IFNAMSIZ] ifr_newname; 50 | char* ifr_data; 51 | } 52 | } 53 | } 54 | 55 | /// Socket pair for DHCP communication 56 | struct DHCPSockets 57 | { 58 | Socket sendSocket; 59 | Socket receiveSocket; 60 | Address sendAddress; 61 | } 62 | 63 | immutable targetBroadcast = "255.255.255.255"; 64 | 65 | /// Parse MAC address from string format (e.g., "01:23:45:67:89:AB") 66 | ubyte[] parseMac(string mac) 67 | { 68 | return mac.split(":").map!(s => s.parse!ubyte(16)).array(); 69 | } 70 | 71 | /// Get network interface index by name (Linux only) 72 | version(linux) 73 | int getIfaceIndex(Socket s, string name) 74 | { 75 | ifreq req; 76 | auto len = min(name.length, req.ifr_name.length); 77 | req.ifr_name[0 .. len] = name[0 .. len]; 78 | errnoEnforce(ioctl(s.handle, SIOCGIFINDEX, &req) == 0, "SIOCGIFINDEX failed"); 79 | return req.ifr_ifindex; 80 | } 81 | 82 | /// Create and configure sockets for DHCP communication 83 | DHCPSockets createSockets( 84 | string target, 85 | ushort serverPort, 86 | bool useRaw, 87 | string iface) 88 | { 89 | DHCPSockets sockets; 90 | 91 | sockets.receiveSocket = new UdpSocket(); 92 | if (target == targetBroadcast) 93 | sockets.receiveSocket.setOption(SocketOptionLevel.SOCKET, SocketOption.BROADCAST, 1); 94 | 95 | if (useRaw) 96 | { 97 | version(linux) 98 | { 99 | static if (is(typeof(AF_PACKET))) 100 | { 101 | sockets.sendSocket = new Socket(cast(AddressFamily)AF_PACKET, SocketType.RAW, ProtocolType.RAW); 102 | 103 | enforce(iface, "Interface not specified, please specify an interface with --iface"); 104 | auto ifaceIndex = getIfaceIndex(sockets.sendSocket, iface); 105 | 106 | enum ETH_ALEN = 6; 107 | auto llAddr = new sockaddr_ll; 108 | llAddr.sll_ifindex = ifaceIndex; 109 | llAddr.sll_halen = ETH_ALEN; 110 | llAddr.sll_addr[0 .. 6] = 0xFF; 111 | sockets.sendAddress = new UnknownAddressReference(cast(sockaddr*)llAddr, sockaddr_ll.sizeof); 112 | } 113 | else 114 | throw new Exception("Raw sockets are not supported on this platform."); 115 | } 116 | else 117 | throw new Exception("Raw sockets are not supported on this platform."); 118 | } 119 | else 120 | { 121 | sockets.sendSocket = sockets.receiveSocket; 122 | sockets.sendAddress = new InternetAddress(target, serverPort); 123 | } 124 | 125 | return sockets; 126 | } 127 | 128 | /// Bind receive socket to interface and port 129 | void bindSocket( 130 | Socket receiveSocket, 131 | string bindAddr, 132 | ushort clientPort, 133 | string iface) 134 | { 135 | version (linux) 136 | { 137 | if (iface) 138 | { 139 | enum SO_BINDTODEVICE = cast(SocketOption)25; 140 | receiveSocket.setOption(SocketOptionLevel.SOCKET, SO_BINDTODEVICE, cast(void[])iface); 141 | } 142 | } 143 | else 144 | enforce(iface is null, "--iface is not available on this platform"); 145 | 146 | receiveSocket.setOption(SocketOptionLevel.SOCKET, SocketOption.REUSEADDR, 1); 147 | receiveSocket.bind(getAddress(bindAddr, clientPort)[0]); 148 | } 149 | 150 | /// Send a DHCP packet via socket 151 | void sendPacket( 152 | Socket socket, 153 | Address addr, 154 | string targetIP, 155 | ubyte[] mac, 156 | DHCPPacket packet, 157 | ushort clientPort, 158 | ushort serverPort) 159 | { 160 | auto data = serializePacket(packet); 161 | 162 | // For raw sockets (Linux), wrap DHCP data in Ethernet/IP/UDP headers 163 | static if (is(typeof(AF_PACKET))) 164 | if (socket.addressFamily != AF_INET) 165 | data = buildRawPacketData(data, targetIP, mac, clientPort, serverPort); 166 | 167 | auto sent = socket.sendTo(data, addr); 168 | errnoEnforce(sent > 0, "sendto error"); 169 | enforce(sent == data.length, "Sent only %d/%d bytes".format(sent, data.length)); 170 | } 171 | 172 | /// Receive DHCP packets with timeout and handler callback 173 | /// Returns: true if a packet was handled successfully, false if timeout 174 | bool receivePackets( 175 | Socket socket, 176 | scope bool delegate(DHCPPacket, Address) handler, 177 | Duration timeout, 178 | scope void delegate(string) onError = null) 179 | { 180 | static ubyte[0x10000] buf; 181 | Address address; 182 | 183 | SysTime start = Clock.currTime(); 184 | SysTime end = start + timeout; 185 | auto set = new SocketSet(1); 186 | 187 | while (true) 188 | { 189 | auto remaining = end - Clock.currTime(); 190 | if (remaining <= Duration.zero) 191 | break; 192 | 193 | set.reset(); 194 | set.add(socket); 195 | int n = Socket.select(set, null, null, remaining); 196 | enforce(n >= 0, "select interrupted"); 197 | if (!n) 198 | break; // timeout exceeded 199 | 200 | auto received = socket.receiveFrom(buf[], address); 201 | if (received <= 0) 202 | throw new Exception("socket.receiveFrom returned %d.".format(received)); 203 | 204 | auto receivedData = buf[0..received].dup; 205 | try 206 | { 207 | auto result = handler(parsePacket(receivedData), address); 208 | if (!result) 209 | return true; 210 | } 211 | catch (Exception e) 212 | { 213 | if (onError) 214 | onError(format("Error while parsing packet [%(%02X %)]: %s", receivedData, e.toString())); 215 | } 216 | } 217 | 218 | // timeout exceeded 219 | return false; 220 | } 221 | -------------------------------------------------------------------------------- /source/dhcptest/dhcptest.d: -------------------------------------------------------------------------------- 1 | /** 2 | * A DHCP testing tool. 3 | * 4 | * License: 5 | * Boost Software License 1.0: 6 | * http://www.boost.org/LICENSE_1_0.txt 7 | * 8 | * Authors: 9 | * Vladimir Panteleev 10 | */ 11 | 12 | module dhcptest.dhcptest; 13 | 14 | import core.thread; 15 | 16 | import std.algorithm; 17 | import std.array; 18 | import std.ascii; 19 | import std.bitmanip; 20 | import std.conv; 21 | import std.datetime; 22 | import std.exception; 23 | import std.format; 24 | import std.getopt; 25 | import std.math : ceil; 26 | import std.random; 27 | import std.range; 28 | import std.socket; 29 | import std.stdio; 30 | import std.string; 31 | import std.traits; 32 | 33 | import dhcptest.formats; 34 | import dhcptest.options; 35 | import dhcptest.packets; 36 | import dhcptest.network; 37 | 38 | version (Windows) 39 | static if (__VERSION__ >= 2067) 40 | import core.sys.windows.winsock2 : ntohs, htons, ntohl, htonl; 41 | else 42 | import std.c.windows.winsock : ntohs, htons, ntohl, htonl; 43 | else 44 | version (Posix) 45 | import core.sys.posix.netdb : ntohs, htons, ntohl, htonl; 46 | else 47 | static assert(false, "Unsupported platform"); 48 | 49 | __gshared string printOnly; 50 | __gshared bool quiet; 51 | __gshared Syntax outputFormat = Syntax.verbose; 52 | 53 | /// Print a DHCP packet to a file 54 | void printPacket(File f, DHCPPacket packet) 55 | { 56 | // Create warning handler that respects the quiet flag 57 | void warningHandler(string msg) 58 | { 59 | if (!quiet) 60 | stderr.writefln("%s", msg); 61 | } 62 | 63 | // Use plain format for --print-only, otherwise use the global format setting 64 | Syntax syntax = (printOnly != null && printOnly.length > 0) ? Syntax.plain : outputFormat; 65 | f.write(formatPacket(packet, printOnly, &warningHandler, syntax)); 66 | f.flush(); 67 | } 68 | 69 | enum SERVER_PORT = 67; 70 | enum CLIENT_PORT = 68; 71 | 72 | ushort serverPort = SERVER_PORT; 73 | ushort clientPort = CLIENT_PORT; 74 | 75 | string[] requestedOptions; 76 | string[] sentOptions; 77 | ushort requestSecs = 0; 78 | uint giaddr; 79 | string target; 80 | 81 | /// Wrapper for generatePacket that uses global state 82 | DHCPPacket generatePacketFromGlobals(ubyte[] mac) 83 | { 84 | try 85 | return dhcptest.packets.generatePacket(mac, requestSecs, giaddr, requestedOptions, sentOptions, target); 86 | catch (Exception e) 87 | { 88 | stderr.writeln("Error with parsing option: ", e.msg); 89 | throw e; 90 | } 91 | } 92 | 93 | int run(string[] args) 94 | { 95 | string bindAddr = "0.0.0.0"; 96 | string iface = null; 97 | target = targetBroadcast; 98 | string giaddrStr = "0.0.0.0"; 99 | ubyte[] defaultMac = 6.iota.map!(i => i == 0 ? ubyte((uniform!ubyte & 0xFC) | 0x02u) : uniform!ubyte).array; 100 | bool help, query, wait, raw; 101 | float timeoutSeconds = 60f; 102 | uint tries = 1; 103 | 104 | enum forever = 1000.days; 105 | 106 | getopt(args, 107 | "h|help", &help, 108 | "bind", &bindAddr, 109 | "target", &target, 110 | "bind-port", &clientPort, 111 | "target-port", &serverPort, 112 | "giaddr", &giaddrStr, 113 | "iface", &iface, 114 | "r|raw", &raw, 115 | "mac", (string mac, string value) { defaultMac = parseMac(value); }, 116 | "secs", &requestSecs, 117 | "q|quiet", &quiet, 118 | "query", &query, 119 | "wait", &wait, 120 | "request", &requestedOptions, 121 | "print-only", &printOnly, 122 | "format", &outputFormat, 123 | "timeout", &timeoutSeconds, 124 | "tries", &tries, 125 | "option", &sentOptions, 126 | ); 127 | 128 | if (wait) enforce(query, "Option --wait only supported with --query"); 129 | 130 | /// https://issues.dlang.org/show_bug.cgi?id=6725 131 | auto timeout = dur!"hnsecs"(cast(long)(convert!("seconds", "hnsecs")(1) * timeoutSeconds)); 132 | 133 | if (!quiet) 134 | { 135 | stderr.writeln("dhcptest v0.9 - Created by Vladimir Panteleev"); 136 | stderr.writeln("https://github.com/CyberShadow/dhcptest"); 137 | stderr.writeln("Run with --help for a list of command-line options."); 138 | stderr.writeln(); 139 | } 140 | 141 | if (help) 142 | { 143 | stderr.writeln("Usage: ", args[0], " [OPTION]..."); 144 | stderr.writeln(); 145 | stderr.writeln("Options:"); 146 | stderr.writeln(" --bind IP Listen on the interface with the specified IP."); 147 | stderr.writeln(" The default is to listen on all interfaces (0.0.0.0)."); 148 | stderr.writeln(" On Linux, you should use --iface instead."); 149 | stderr.writeln(" --target IP Instead of sending a broadcast packet, send a normal packet"); 150 | stderr.writeln(" to this IP."); 151 | stderr.writeln(" --bind-port N Listen on and send packets from this port number instead of"); 152 | stderr.writeln(" the standard %d.".format(CLIENT_PORT)); 153 | stderr.writeln(" --target-port N Send packets to this port instead of the standard %d.".format(SERVER_PORT)); 154 | stderr.writeln(" --giaddr IP Set giaddr to the specified relay agent IP address."); 155 | stderr.writeln(" --iface NAME Bind to the specified network interface name. Linux only."); 156 | stderr.writeln(" --raw Use raw sockets. Allows spoofing the MAC address in the "); 157 | stderr.writeln(" Ethernet header. Linux only. Use with --iface."); 158 | stderr.writeln(" --mac MAC Specify a MAC address to use for the client hardware"); 159 | stderr.writeln(" address field (chaddr), in the format NN:NN:NN:NN:NN:NN"); 160 | stderr.writeln(" --secs Specify the \"Secs\" request field (number of seconds elapsed"); 161 | stderr.writeln(" since a client began an attempt to acquire or renew a lease)"); 162 | stderr.writeln(" --quiet Suppress program output except for received data"); 163 | stderr.writeln(" and error messages"); 164 | stderr.writeln(" --query Instead of starting an interactive prompt, immediately send"); 165 | stderr.writeln(" a discover packet, wait for a result, print it and exit."); 166 | stderr.writeln(" --wait Wait until timeout elapsed before exiting from --query, all"); 167 | stderr.writeln(" offers returned will be reported."); 168 | stderr.writeln(" --option OPTION Add an option to the request packet. The option must be"); 169 | stderr.writeln(" specified using the syntax CODE=VALUE or CODE[FORMAT]=VALUE,"); 170 | stderr.writeln(" where CODE is the numeric option number, FORMAT is how the"); 171 | stderr.writeln(" value is to be interpreted and decoded, and VALUE is the"); 172 | stderr.writeln(" option value. FORMAT may be omitted for known option CODEs"); 173 | stderr.writeln(" E.g. to specify a Vendor Class Identifier:"); 174 | stderr.writeln(" --option \"60=Initech Groupware\""); 175 | stderr.writeln(" You can specify hexadecimal or IPv4-formatted options using"); 176 | stderr.writeln(" --option \"N[hex]=...\" or --option \"N[IP]=...\""); 177 | stderr.writeln(" Supported FORMAT types:"); 178 | stderr.write("%-(%s, %)".format(EnumMembers!OptionFormat[1..$].only.uniq).wrap(79, 179 | /* */ " ", 180 | /* */ " ")); 181 | stderr.writeln(" --request N Uses DHCP option 55 (\"Parameter Request List\") to"); 182 | stderr.writeln(" explicitly request the specified option from the server."); 183 | stderr.writeln(" Can be repeated several times to request multiple options."); 184 | stderr.writeln(" --print-only N Print only the specified DHCP option."); 185 | stderr.writeln(" You can specify a desired format using the syntax N[FORMAT]"); 186 | stderr.writeln(" See above for a list of FORMATs. For example:"); 187 | stderr.writeln(" --print-only \"N[hex]\" or --print-only \"N[IP]\""); 188 | stderr.writeln(" When --print-only is used, output defaults to plain format."); 189 | stderr.writeln(" --format SYNTAX Control the output format. SYNTAX can be:"); 190 | stderr.writeln(" verbose: Human-readable with comments (default)"); 191 | stderr.writeln(" plain: Human-readable without comments"); 192 | stderr.writeln(" json: Machine-readable (JSON syntax, no comments)"); 193 | stderr.writeln(" --timeout N Wait N seconds for a reply, after which retry or exit."); 194 | stderr.writeln(" Default is 60 seconds. Can be a fractional number."); 195 | stderr.writeln(" A value of 0 causes dhcptest to wait indefinitely."); 196 | stderr.writeln(" --tries N Send N DHCP discover packets after each timeout interval."); 197 | stderr.writeln(" Specify N=0 to retry indefinitely."); 198 | return 0; 199 | } 200 | 201 | // Create and configure sockets 202 | auto sockets = createSockets(target, serverPort, raw, iface); 203 | 204 | // Parse giaddr 205 | giaddr = (new InternetAddress(giaddrStr, 0)).addr.htonl(); 206 | 207 | void bindSocketWithLogging() 208 | { 209 | bindSocket(sockets.receiveSocket, bindAddr, clientPort, iface); 210 | if (!quiet) stderr.writefln("Listening for DHCP replies on port %d.", clientPort); 211 | } 212 | 213 | void runPrompt() 214 | { 215 | try 216 | bindSocketWithLogging(); 217 | catch (Exception e) 218 | { 219 | stderr.writeln("Error while attempting to bind socket:"); 220 | stderr.writeln(e.msg); 221 | stderr.writeln("Replies will not be visible. Use a packet capture tool to see replies,"); 222 | stderr.writeln("or try re-running the program with more permissions."); 223 | } 224 | 225 | void listenThread() 226 | { 227 | try 228 | { 229 | sockets.receiveSocket.receivePackets((DHCPPacket packet, Address address) 230 | { 231 | if (!quiet) stderr.writefln("Received packet from %s:", address); 232 | stdout.printPacket(packet); 233 | return true; 234 | }, forever, (msg) { if (!quiet) stderr.writefln("%s", msg); }); 235 | } 236 | catch (Exception e) 237 | { 238 | stderr.writeln("Error on listening thread:"); 239 | stderr.writeln(e.toString()); 240 | } 241 | } 242 | 243 | auto t = new Thread(&listenThread); 244 | t.isDaemon = true; 245 | t.start(); 246 | 247 | if (!quiet) stderr.writeln(`Type "d" to broadcast a DHCP discover packet, or "help" for details.`); 248 | while (!stdin.eof) 249 | { 250 | auto line = readln().strip().split(); 251 | if (!line.length) 252 | { 253 | if (!stdin.eof) 254 | stderr.writeln("Enter a command."); 255 | continue; 256 | } 257 | 258 | switch (line[0].toLower()) 259 | { 260 | case "d": 261 | case "discover": 262 | { 263 | ubyte[] mac = line.length > 1 ? parseMac(line[1]) : defaultMac; 264 | auto packet = generatePacketFromGlobals(mac); 265 | if (!quiet) 266 | { 267 | stderr.writefln("Sending packet:"); 268 | stderr.printPacket(packet); 269 | } 270 | sockets.sendSocket.sendPacket(sockets.sendAddress, target, mac, packet, clientPort, serverPort); 271 | break; 272 | } 273 | 274 | case "q": 275 | case "quit": 276 | case "exit": 277 | return; 278 | 279 | case "help": 280 | case "?": 281 | stderr.writeln("Commands:"); 282 | stderr.writeln(" d / discover"); 283 | stderr.writeln(" Broadcasts a DHCP discover packet."); 284 | stderr.writeln(" You can optionally specify a part or an entire MAC address"); 285 | stderr.writeln(" to use for the client hardware address field (chaddr), e.g."); 286 | stderr.writeln(` "d 01:23:45" will use the specified first 3 octets and`); 287 | stderr.writeln(` randomly generate the rest.`); 288 | stderr.writeln(` help`); 289 | stderr.writeln(` Print this message.`); 290 | stderr.writeln(` q / quit`); 291 | stderr.writeln(` Quits the program.`); 292 | break; 293 | default: 294 | stderr.writeln("Unrecognized command."); 295 | } 296 | } 297 | } 298 | 299 | int runQuery() 300 | { 301 | if (tries == 0) 302 | tries = tries.max; 303 | if (timeout == Duration.zero) 304 | timeout = forever; 305 | 306 | bindSocketWithLogging(); 307 | auto sentPacket = generatePacketFromGlobals(defaultMac); 308 | if (!quiet) 309 | { 310 | stderr.writefln("Sending packet:"); 311 | stderr.printPacket(sentPacket); 312 | } 313 | 314 | int count = 0; 315 | 316 | foreach (t; 0..tries) 317 | { 318 | if (!quiet && t) stderr.writefln("Retrying, try %d...", t+1); 319 | 320 | SysTime start = Clock.currTime(); 321 | SysTime end = start + timeout; 322 | 323 | sockets.sendSocket.sendPacket(sockets.sendAddress, target, defaultMac, sentPacket, clientPort, serverPort); 324 | 325 | while (true) 326 | { 327 | auto remaining = end - Clock.currTime(); 328 | if (remaining <= Duration.zero) 329 | break; 330 | 331 | auto result = sockets.receiveSocket.receivePackets((DHCPPacket packet, Address address) 332 | { 333 | if (packet.header.xid != sentPacket.header.xid) 334 | return true; 335 | if (!quiet) stderr.writefln("Received packet from %s:", address); 336 | stdout.printPacket(packet); 337 | return false; 338 | }, remaining, (msg) { if (!quiet) stderr.writefln("%s", msg); }); 339 | 340 | if (result && !wait) // Got reply packet and do not wait for all query responses 341 | return 0; 342 | 343 | if (result) // Got reply packet? 344 | count++; 345 | } 346 | 347 | if (count) // Did we get any responses? 348 | return 0; 349 | } 350 | 351 | if (!quiet) stderr.writefln("Giving up after %d %s.", tries, tries==1 ? "try" : "tries"); 352 | return 1; 353 | } 354 | 355 | if (query) 356 | return runQuery(); 357 | else 358 | { 359 | runPrompt(); 360 | return 0; 361 | } 362 | } 363 | 364 | version(unittest) {} else 365 | int main(string[] args) 366 | { 367 | debug 368 | return run(args); 369 | else 370 | { 371 | try 372 | return run(args); 373 | catch (Exception e) 374 | { 375 | stderr.writeln("Fatal error: ", e.msg); 376 | return 1; 377 | } 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /source/dhcptest/packets.d: -------------------------------------------------------------------------------- 1 | module dhcptest.packets; 2 | 3 | import std.algorithm; 4 | import std.array; 5 | import std.bitmanip; 6 | import std.conv; 7 | import std.exception; 8 | import std.format; 9 | import std.random; 10 | import std.range; 11 | import std.socket; 12 | import std.string; 13 | 14 | import dhcptest.formats; 15 | import dhcptest.network; 16 | import dhcptest.options; 17 | 18 | version (Windows) 19 | static if (__VERSION__ >= 2067) 20 | import core.sys.windows.winsock2 : ntohs, htons, ntohl, htonl; 21 | else 22 | import std.c.windows.winsock : ntohs, htons, ntohl, htonl; 23 | else 24 | version (Posix) 25 | { 26 | import core.sys.posix.netdb : ntohs, htons, ntohl, htonl; 27 | import core.sys.posix.arpa.inet : inet_pton; 28 | } 29 | else 30 | static assert(false, "Unsupported platform"); 31 | 32 | version (linux) 33 | { 34 | import core.sys.linux.sys.socket; 35 | import core.sys.posix.net.if_ : IF_NAMESIZE; 36 | 37 | enum IFNAMSIZ = IF_NAMESIZE; 38 | 39 | extern(C) struct sockaddr_ll 40 | { 41 | ushort sll_family; 42 | ushort sll_protocol; 43 | int sll_ifindex; 44 | ushort sll_hatype; 45 | ubyte sll_pkttype; 46 | ubyte sll_halen; 47 | ubyte[8] sll_addr; 48 | } 49 | 50 | struct ether_header 51 | { 52 | ubyte[6] ether_dhost; 53 | ubyte[6] ether_shost; 54 | ushort ether_type; 55 | } 56 | 57 | struct iphdr 58 | { 59 | mixin(bitfields!( 60 | ubyte, q{ihl}, 4, 61 | ubyte, q{ver}, 4, 62 | )); 63 | ubyte tos; 64 | ushort tot_len; 65 | ushort id; 66 | ushort frag_off; 67 | ubyte ttl; 68 | ubyte protocol; 69 | ushort check; 70 | uint saddr; 71 | uint daddr; 72 | } 73 | 74 | struct udphdr 75 | { 76 | ushort uh_sport; 77 | ushort uh_dport; 78 | ushort uh_ulen; 79 | ushort uh_sum; 80 | } 81 | 82 | enum ETH_P_IP = 0x0800; 83 | enum IP_DF = 0x4000; 84 | } 85 | 86 | /// Header (part up to the option fields) of a DHCP packet, as on wire. 87 | align(1) 88 | struct DHCPHeader 89 | { 90 | align(1): 91 | /// Message op code / message type. 1 = BOOTREQUEST, 2 = BOOTREPLY 92 | ubyte op; 93 | 94 | /// Hardware address type, see ARP section in "Assigned Numbers" RFC; e.g., '1' = 10mb ethernet. 95 | ubyte htype; 96 | 97 | /// Hardware address length (e.g. '6' for 10mb ethernet). 98 | ubyte hlen; 99 | 100 | /// Client sets to zero, optionally used by relay agents when booting via a relay agent. 101 | ubyte hops; 102 | 103 | /// Transaction ID, a random number chosen by the client, used by the client and server to associate messages and responses between a client and a server. 104 | uint xid; 105 | 106 | /// Filled in by client, seconds elapsed since client began address acquisition or renewal process. 107 | ushort secs; 108 | 109 | /// Flags. (Only the BROADCAST flag is defined.) 110 | ushort flags; 111 | 112 | /// Client IP address; only filled in if client is in BOUND, RENEW or REBINDING state and can respond to ARP requests. 113 | uint ciaddr; 114 | 115 | /// 'your' (client) IP address. 116 | uint yiaddr; 117 | 118 | /// IP address of next server to use in bootstrap; returned in DHCPOFFER, DHCPACK by server. 119 | uint siaddr; 120 | 121 | /// Relay agent IP address, used in booting via a relay agent. 122 | uint giaddr; 123 | 124 | /// Client hardware address. 125 | ubyte[16] chaddr; 126 | 127 | /// Optional server host name, null terminated string. 128 | char[64] sname = 0; 129 | 130 | /// Boot file name, null terminated string; "generic" name or null in DHCPDISCOVER, fully qualified directory-path name in DHCPOFFER. 131 | char[128] file = 0; 132 | 133 | /// Optional parameters field. See the options documents for a list of defined options. 134 | ubyte[0] options; 135 | 136 | static assert(DHCPHeader.sizeof == 236); 137 | } 138 | 139 | struct DHCPOption 140 | { 141 | ubyte type; 142 | ubyte[] data; 143 | } 144 | 145 | struct DHCPPacket 146 | { 147 | DHCPHeader header; 148 | DHCPOption[] options; 149 | } 150 | 151 | /// Parse DHCP packet from wire format 152 | DHCPPacket parsePacket(ubyte[] data) 153 | { 154 | DHCPPacket result; 155 | 156 | enforce(data.length > DHCPHeader.sizeof + 4, "DHCP packet too small"); 157 | result.header = *cast(DHCPHeader*)data.ptr; 158 | data = data[DHCPHeader.sizeof..$]; 159 | 160 | enforce(data[0..4] == [99, 130, 83, 99], "Absent DHCP option magic cookie"); 161 | data = data[4..$]; 162 | 163 | ubyte readByte() 164 | { 165 | enforce(data.length, "Unexpected end of packet"); 166 | ubyte b = data[0]; 167 | data = data[1..$]; 168 | return b; 169 | } 170 | 171 | while (true) 172 | { 173 | auto optionType = readByte(); 174 | if (optionType==0) // pad option 175 | continue; 176 | if (optionType==255) // end option 177 | break; 178 | 179 | auto len = readByte(); 180 | DHCPOption option; 181 | option.type = optionType; 182 | foreach (n; 0..len) 183 | option.data ~= readByte(); 184 | result.options ~= option; 185 | } 186 | 187 | return result; 188 | } 189 | 190 | /// Serialize DHCP packet to wire format 191 | ubyte[] serializePacket(DHCPPacket packet) 192 | { 193 | ubyte[] data; 194 | data ~= cast(ubyte[])((&packet.header)[0..1]); 195 | data ~= [99, 130, 83, 99]; 196 | foreach (option; packet.options) 197 | { 198 | data ~= option.type; 199 | data ~= to!ubyte(option.data.length); 200 | data ~= option.data; 201 | } 202 | data ~= 255; 203 | return data; 204 | } 205 | 206 | /// Generate a DHCP request packet 207 | DHCPPacket generatePacket( 208 | ubyte[] mac, 209 | ushort requestSecs, 210 | uint giaddr, 211 | string[] requestedOptions, 212 | string[] sentOptions, 213 | string target = null, 214 | uint xid = uniform!uint()) 215 | { 216 | DHCPPacket packet; 217 | packet.header.op = 1; // BOOTREQUEST 218 | packet.header.htype = 1; 219 | packet.header.hlen = mac.length.to!ubyte; 220 | packet.header.hops = 0; 221 | packet.header.xid = xid; 222 | packet.header.secs = requestSecs; 223 | packet.header.flags = 0; 224 | // Set BROADCAST flag when sending to broadcast address (required to receive replies to imaginary hardware addresses) 225 | if (target == targetBroadcast) 226 | packet.header.flags |= htons(0x8000); 227 | packet.header.chaddr[0..mac.length] = mac; 228 | packet.header.giaddr = giaddr; 229 | if (requestedOptions.length) 230 | packet.options ~= DHCPOption(DHCPOptionType.parameterRequestList, cast(ubyte[])requestedOptions.map!parseDHCPOptionType.array); 231 | foreach (option; sentOptions) 232 | { 233 | // Parse "optionName[format]=value" using OptionFormat.option 234 | // Encoding: [DHCPOptionType byte][value bytes...] 235 | auto parsedOption = parseOption(option, OptionFormat.option); 236 | auto opt = cast(DHCPOptionType)parsedOption[0]; 237 | auto bytes = parsedOption[1 .. $]; 238 | packet.options ~= DHCPOption(opt, bytes); 239 | } 240 | if (packet.options.all!(option => option.type != DHCPOptionType.dhcpMessageType)) 241 | packet.options = DHCPOption(DHCPOptionType.dhcpMessageType, [DHCPMessageType.discover]) ~ packet.options; 242 | return packet; 243 | } 244 | 245 | unittest 246 | { 247 | auto mac = cast(ubyte[])[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; 248 | uint testXid = 0x12345678; 249 | 250 | auto packet = generatePacket( 251 | mac, 252 | 10, // requestSecs 253 | 0, // giaddr 254 | [], 255 | ["12=testhost", "60=vendor123"], 256 | targetBroadcast, // target 257 | testXid 258 | ); 259 | 260 | DHCPPacket expected; 261 | expected.header.op = 1; // BOOTREQUEST 262 | expected.header.htype = 1; 263 | expected.header.hlen = 6; 264 | expected.header.hops = 0; 265 | expected.header.xid = 0x12345678; 266 | expected.header.secs = 10; 267 | expected.header.flags = htons(0x8000); 268 | expected.header.chaddr[0..6] = mac; 269 | expected.options = [ 270 | DHCPOption(53, [1]), // dhcpMessageType: discover 271 | DHCPOption(12, [116, 101, 115, 116, 104, 111, 115, 116]), // hostname: testhost 272 | DHCPOption(60, [118, 101, 110, 100, 111, 114, 49, 50, 51]), // vendor: vendor123 273 | ]; 274 | 275 | assert(packet == expected); 276 | } 277 | 278 | /// Calculate IP checksum 279 | ushort ipChecksum(void[] data) 280 | { 281 | if (data.length % 2) 282 | data.length = data.length + 1; 283 | auto words = cast(ushort[])data; 284 | uint checksum = 0xffff; 285 | 286 | foreach (word; words) 287 | { 288 | checksum += ntohs(word); 289 | if (checksum > 0xffff) 290 | checksum -= 0xffff; 291 | } 292 | 293 | return htons((~checksum) & 0xFFFF); 294 | } 295 | 296 | /// Build raw packet data with Ethernet/IP/UDP headers (raw socket mode) 297 | static if (is(typeof(AF_PACKET))) 298 | ubyte[] buildRawPacketData( 299 | ubyte[] dhcpData, 300 | string targetIP, 301 | ubyte[] mac, 302 | ushort clientPort, 303 | ushort serverPort) 304 | { 305 | static struct Header 306 | { 307 | align(1): 308 | ether_header ether; 309 | iphdr ip; 310 | udphdr udp; 311 | } 312 | Header header; 313 | header.ether.ether_dhost[] = 0xFF; // broadcast 314 | header.ether.ether_shost[] = mac; 315 | header.ether.ether_type = ETH_P_IP.htons; 316 | static assert(iphdr.sizeof % 4 == 0); 317 | header.ip.ihl = iphdr.sizeof / 4; 318 | header.ip.ver = 4; 319 | header.ip.tot_len = (header.ip.sizeof + header.udp.sizeof + dhcpData.length).to!ushort.htons; 320 | static ushort idCounter; 321 | header.ip.id = ++idCounter; 322 | // header.ip.frag_off = IP_DF.htons; 323 | header.ip.ttl = 0x40; 324 | header.ip.protocol = IPPROTO_UDP; 325 | header.ip.saddr = 0x00000000; // 0.0.0.0 326 | inet_pton(AF_INET, targetIP.toStringz, &header.ip.daddr).enforce("Invalid target IP address"); 327 | header.ip.check = ipChecksum((&header.ip)[0..1]); 328 | 329 | header.udp.uh_sport = clientPort.htons; 330 | header.udp.uh_dport = serverPort.htons; 331 | header.udp.uh_ulen = (header.udp.sizeof + dhcpData.length).to!ushort.htons; 332 | 333 | static struct UDPChecksumData 334 | { 335 | uint saddr; 336 | uint daddr; 337 | ubyte zeroes = 0x0; 338 | ubyte proto = IPPROTO_UDP; 339 | ushort udp_len; 340 | udphdr udp; 341 | } 342 | UDPChecksumData udpChecksumData; 343 | udpChecksumData.saddr = header.ip.saddr; 344 | udpChecksumData.daddr = header.ip.daddr; 345 | udpChecksumData.udp_len = header.udp.uh_ulen; 346 | udpChecksumData.udp = header.udp; 347 | header.udp.uh_sum = ipChecksum(cast(ubyte[])(&udpChecksumData)[0..1] ~ dhcpData); 348 | 349 | return cast(ubyte[])(&header)[0..1] ~ dhcpData; 350 | } 351 | 352 | /// Format a DHCP packet as a human-readable string 353 | string formatPacket( 354 | DHCPPacket packet, 355 | string printOnlyOption = null, 356 | scope void delegate(string) onWarning = null, 357 | Syntax syntax = Syntax.verbose) 358 | { 359 | import std.ascii : isAlpha; 360 | 361 | // If printing only a specific option 362 | if (printOnlyOption != null && printOnlyOption.length > 0) 363 | { 364 | // Parse "optionName[format]" to get the option type and format 365 | auto parser = OptionParser(printOnlyOption, true); 366 | auto spec = parser.parseFieldSpec(); 367 | 368 | string optionName = spec[0]; 369 | auto formatOverride = spec[1]; 370 | 371 | auto opt = parseDHCPOptionType(optionName); 372 | 373 | // Use format override if specified, otherwise use default for this option 374 | OptionFormat fmt = formatOverride.isNull 375 | ? dhcpOptions.get(opt, DHCPOptionSpec.init).format 376 | : formatOverride.get; 377 | 378 | foreach (option; packet.options) 379 | { 380 | if (option.type == opt) 381 | { 382 | return formatRawOption(option.data, fmt, onWarning); 383 | } 384 | } 385 | 386 | // Option not found, call warning delegate if provided 387 | if (onWarning) 388 | onWarning(format("(No option %s in packet)", opt)); 389 | return ""; 390 | } 391 | 392 | // Format full packet 393 | auto output = appender!string(); 394 | auto opNames = [1:"BOOTREQUEST",2:"BOOTREPLY"]; 395 | output.formattedWrite!" op=%s chaddr=%(%02X:%) hops=%d xid=%08X secs=%d flags=%04X\n ciaddr=%s yiaddr=%s siaddr=%s giaddr=%s sname=%s file=%s\n"( 396 | opNames.get(packet.header.op, packet.header.op.to!string), 397 | packet.header.chaddr[0..packet.header.hlen], 398 | packet.header.hops, 399 | ntohl(packet.header.xid), 400 | ntohs(packet.header.secs), 401 | ntohs(packet.header.flags), 402 | ip(packet.header.ciaddr), 403 | ip(packet.header.yiaddr), 404 | ip(packet.header.siaddr), 405 | ip(packet.header.giaddr), 406 | packet.header.sname.ptr.to!string, 407 | packet.header.file.ptr.to!string, 408 | ); 409 | 410 | output.formattedWrite!" %d options:\n"(packet.options.length); 411 | foreach (option; packet.options) 412 | { 413 | auto type = cast(DHCPOptionType)option.type; 414 | auto fmt = dhcpOptions.get(type, DHCPOptionSpec.init).format; 415 | 416 | // Try to format the option value 417 | string formattedValue; 418 | bool usedHexFallback = false; 419 | 420 | try 421 | { 422 | formattedValue = formatOption(option.data, fmt, syntax, onWarning); 423 | } 424 | catch (Exception e) 425 | { 426 | // Emit warning if callback is set 427 | if (onWarning) 428 | onWarning(format("Error formatting option %s as %s: %s (displaying as hex)", 429 | formatDHCPOptionType(type), fmt, e.msg)); 430 | 431 | // Fall back to hex format 432 | formattedValue = formatOption(option.data, OptionFormat.hex, syntax, onWarning); 433 | usedHexFallback = true; 434 | } 435 | 436 | // Output the option with appropriate annotation 437 | if (usedHexFallback) 438 | output.formattedWrite!" %s[hex]: "(formatDHCPOptionType(type)); 439 | else 440 | output.formattedWrite!" %s: "(formatDHCPOptionType(type)); 441 | 442 | output.put(formattedValue); 443 | output.put("\n"); 444 | } 445 | 446 | return output.data; 447 | } 448 | 449 | unittest 450 | { 451 | auto mac = cast(ubyte[])[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; 452 | uint testXid = 0x12345678; 453 | 454 | auto packet = generatePacket( 455 | mac, 456 | 10, // requestSecs 457 | 0, // giaddr 458 | [], 459 | ["12=testhost", "60=vendor123"], 460 | targetBroadcast, // target 461 | testXid 462 | ); 463 | 464 | auto formatted = formatPacket(packet); 465 | 466 | assert(formatted == q"EOF 467 | op=BOOTREQUEST chaddr=AA:BB:CC:DD:EE:FF hops=0 xid=78563412 secs=2560 flags=8000 468 | ciaddr=0.0.0.0 yiaddr=0.0.0.0 siaddr=0.0.0.0 giaddr=0.0.0.0 sname= file= 469 | 3 options: 470 | 53 (DHCP Message Type): discover 471 | 12 (Host Name Option): testhost 472 | 60 (Vendor class identifier): vendor123 473 | EOF"); 474 | } 475 | -------------------------------------------------------------------------------- /source/dhcptest/formats/types.d: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions and utility functions for DHCP option formatting. 3 | * 4 | * This module provides type definitions and helper functions used throughout 5 | * the formats package for parsing and formatting DHCP options. 6 | */ 7 | module dhcptest.formats.types; 8 | 9 | import std.algorithm; 10 | import std.array; 11 | import std.ascii : isDigit; 12 | import std.conv; 13 | import std.datetime; 14 | import std.exception : enforce; 15 | import std.format; 16 | import std.range; 17 | import std.string; 18 | import std.typecons : Tuple, tuple; 19 | 20 | // Import DHCP-specific types and helpers 21 | public import dhcptest.options : DHCPMessageType, DHCPOptionType, NETBIOSNodeType, NETBIOSNodeTypeChars, ProcessorArchitecture, parseDHCPOptionType, formatDHCPOptionType, parseProcessorArchitecture, formatProcessorArchitecture; 22 | 23 | // Import network byte order functions 24 | version (Windows) 25 | static if (__VERSION__ >= 2067) 26 | import core.sys.windows.winsock2 : ntohl, ntohs, htons, htonl; 27 | else 28 | import std.c.windows.winsock : ntohl, ntohs, htons, htonl; 29 | else 30 | version (Posix) 31 | import core.sys.posix.netdb : ntohl, ntohs, htons, htonl; 32 | else 33 | static assert(false, "Unsupported platform"); 34 | 35 | // Utility functions for formatting DHCP packet fields 36 | string ip(uint addr) { return "%(%d.%)".format(cast(ubyte[])((&addr)[0..1])); } 37 | 38 | /// Option format types - replaces the old formats.d OptionFormat enum 39 | /// This enum specifies how DHCP option data should be parsed and formatted 40 | enum OptionFormat 41 | { 42 | // Special types (not directly parseable/formattable) 43 | unknown, /// Unknown format (not parseable) 44 | special, /// Special option format (not parseable) 45 | 46 | // Scalar types 47 | str, /// Regular string (quoted or unquoted with escaping) 48 | fullString, /// Greedy raw string (top-level only, no escaping) 49 | hex, /// Hex bytes (e.g., "DEADBEEF") 50 | ip, /// Single IP address (e.g., "192.168.1.1") 51 | boolean, /// Boolean value (true/false) 52 | u8, /// Single unsigned 8-bit integer 53 | u16, /// Single unsigned 16-bit integer (network byte order) 54 | u32, /// Single unsigned 32-bit integer (network byte order) 55 | duration, /// Duration value in seconds (u32) 56 | dhcpMessageType, /// DHCP message type enum 57 | dhcpOptionType, /// DHCP option type 58 | netbiosNodeType, /// NetBIOS node type 59 | processorArchitecture, /// Processor architecture type (u16, RFC 4578/5970) 60 | zeroLength, /// Zero-length option (must be "present") 61 | 62 | // Array types (plural naming) 63 | ips, /// Array of IP addresses 64 | u8s, /// Array of u8 values 65 | u16s, /// Array of u16 values 66 | u32s, /// Array of u32 values 67 | durations, /// Array of duration values 68 | dhcpOptionTypes, /// Array of DHCP option types 69 | 70 | // Struct/composite types 71 | relayAgent, /// Relay agent information (RFC 3046) 72 | vendorSpecificInformation, /// Vendor-specific information 73 | classlessStaticRoute, /// Classless static routes (RFC 3442) 74 | clientIdentifier, /// Client identifier (type + data) 75 | clientFQDN, /// Client FQDN (flags + domain name, RFC 4702) 76 | userClass, /// User Class (array of length-prefixed strings, RFC 3004) 77 | domainSearch, /// Domain Search List (array of DNS names, RFC 3397) 78 | option, /// DHCP option specification: name[format]=value 79 | 80 | // Backwards compatibility aliases (deprecated) 81 | IP = ip, /// Deprecated: use 'ip' instead 82 | i32 = u32, /// Deprecated: use 'u32' instead 83 | time = duration, /// Deprecated: use 'duration' instead 84 | } 85 | 86 | /// Output syntax style for formatting 87 | enum Syntax 88 | { 89 | verbose, /// Verbose DSL syntax with comments and decorations 90 | plain, /// Plain DSL syntax without comments (machine-readable) 91 | json, /// JSON syntax: {"field": value}, quoted names 92 | } 93 | 94 | // ============================================================================ 95 | // Relay Agent Information and Vendor-Specific Information 96 | // ============================================================================ 97 | 98 | /// Relay Agent Information suboption types (RFC 3046) 99 | enum RelayAgentSuboption : ubyte 100 | { 101 | agentCircuitID = 1, 102 | agentRemoteID = 2, 103 | } 104 | 105 | /// Vendor-Specific Information suboption types 106 | /// (No standard suboptions defined - all are vendor-specific) 107 | /// This is just an alias to ubyte since there are no predefined types 108 | alias VendorSpecificSuboption = ubyte; 109 | 110 | // ============================================================================ 111 | // Helper functions for enhanced display 112 | // ============================================================================ 113 | 114 | /// Parse time value with optional unit suffix 115 | /// Supports: "3600", "1h", "60m", "3600s" 116 | /// Returns value in seconds 117 | uint parseTimeValue(string s) 118 | { 119 | s = s.strip(); 120 | 121 | // Check for unit suffix 122 | if (s.length > 1 && !s[$-1].isDigit) 123 | { 124 | char unit = s[$-1]; 125 | string numPart = s[0..$-1]; 126 | uint value = numPart.to!uint; 127 | 128 | switch (unit) 129 | { 130 | case 's': case 'S': 131 | return value; // seconds 132 | case 'm': case 'M': 133 | return value * 60; // minutes to seconds 134 | case 'h': case 'H': 135 | return value * 3600; // hours to seconds 136 | case 'd': case 'D': 137 | return value * 86400; // days to seconds 138 | default: 139 | throw new Exception(format("Unknown time unit: '%s'", unit)); 140 | } 141 | } 142 | 143 | // No unit - interpret as seconds 144 | return s.to!uint; 145 | } 146 | 147 | /// Format hex bytes, showing ASCII interpretation if all bytes are printable 148 | /// Parseable format: hex first, then ASCII comment 149 | /// Example: "74 65 73 74 (test)" 150 | string maybeAscii(in ubyte[] bytes) 151 | { 152 | if (bytes.length == 0) 153 | return ""; 154 | 155 | auto s = bytes.map!(b => format("%02X", b)).join(" "); 156 | if (bytes.all!(b => (b >= 0x20 && b <= 0x7E) || !b)) 157 | { 158 | auto ascii = (cast(string)bytes).split("\0").join(", "); 159 | s = "%s (%s)".format(s, ascii); 160 | } 161 | return s; 162 | } 163 | 164 | /// Format classless static routes from bytes 165 | /// Example: [0x18, 0xc0, 0xa8, 0x02, 0xc0, 0xa8, 0x01, 0x32] -> "192.168.2.0/24 -> 192.168.1.50" 166 | string[] formatClasslessStaticRoute(in ubyte[] bytes) 167 | { 168 | string[] result; 169 | size_t i = 0; 170 | while (i < bytes.length) 171 | { 172 | try 173 | { 174 | ubyte maskBits = bytes[i++]; 175 | enforce(maskBits <= 32, "Too many bits in mask length"); 176 | 177 | ubyte[4] subnet = 0; 178 | ubyte subnetSignificantBytes = (maskBits + 7) / 8; 179 | enforce(i + subnetSignificantBytes <= bytes.length, "Not enough bytes for route subnet"); 180 | subnet[0 .. subnetSignificantBytes] = bytes[i .. i + subnetSignificantBytes]; 181 | i += subnetSignificantBytes; 182 | 183 | ubyte[4] routerIP; 184 | enforce(i + 4 <= bytes.length, "Not enough bytes for router IP"); 185 | routerIP[] = bytes[i .. i + 4]; 186 | i += 4; 187 | 188 | result ~= format!"%(%d.%)/%d -> %(%d.%)"(subnet[], maskBits, routerIP); 189 | } 190 | catch (Exception e) 191 | { 192 | result ~= format!"(Error: %s) %(%02x %)"(e.msg, bytes); 193 | break; 194 | } 195 | } 196 | return result; 197 | } 198 | 199 | /// Parse classless static route from string 200 | /// Example: "192.168.2.0/24 -> 192.168.1.50" -> [0x18, 0xc0, 0xa8, 0x02, 0xc0, 0xa8, 0x01, 0x32] 201 | ubyte[] parseClasslessStaticRoute(string s) 202 | { 203 | s = s.strip(); 204 | 205 | // Split by "->" 206 | auto parts = s.split("->"); 207 | enforce(parts.length == 2, "Classless static route must have format: subnet/mask -> router"); 208 | 209 | // Parse subnet and mask 210 | auto subnetPart = parts[0].strip(); 211 | auto subnetParts = subnetPart.split("/"); 212 | enforce(subnetParts.length == 2, "Subnet must have format: IP/mask"); 213 | 214 | // Parse subnet IP 215 | auto subnetIP = subnetParts[0].strip().split(".").map!(to!ubyte).array; 216 | enforce(subnetIP.length == 4, "Subnet IP must have 4 octets"); 217 | 218 | // Parse mask bits 219 | auto maskBits = subnetParts[1].strip().to!ubyte; 220 | enforce(maskBits <= 32, "Mask bits must be <= 32"); 221 | 222 | // Parse router IP 223 | auto routerIP = parts[1].strip().split(".").map!(to!ubyte).array; 224 | enforce(routerIP.length == 4, "Router IP must have 4 octets"); 225 | 226 | // Encode as bytes 227 | ubyte[] result; 228 | result ~= maskBits; 229 | 230 | // Add significant subnet bytes 231 | ubyte significantBytes = (maskBits + 7) / 8; 232 | result ~= subnetIP[0 .. significantBytes]; 233 | 234 | // Add router IP 235 | result ~= routerIP; 236 | 237 | return result; 238 | } 239 | 240 | // ============================================================================ 241 | // DNS Wire Format Helpers (RFC 1035) 242 | // ============================================================================ 243 | 244 | /// Format DNS wire format domain name to human-readable string 245 | /// DNS wire format uses length-prefixed labels: [len][label][len][label]...[0x00] 246 | /// Example: [0x07 "example" 0x03 "com" 0x00] -> "example.com" 247 | string formatDNSName(in ubyte[] bytes) 248 | { 249 | if (bytes.length == 0) 250 | return ""; 251 | 252 | string[] labels; 253 | size_t i = 0; 254 | 255 | while (i < bytes.length) 256 | { 257 | ubyte len = bytes[i++]; 258 | 259 | // Zero-length label marks end of name 260 | if (len == 0) 261 | break; 262 | 263 | // Ensure we have enough bytes for this label 264 | enforce(i + len <= bytes.length, "DNS name label extends past end of data"); 265 | 266 | // Extract label as string 267 | labels ~= cast(string)bytes[i .. i + len]; 268 | i += len; 269 | } 270 | 271 | return labels.join("."); 272 | } 273 | 274 | /// Parse human-readable domain name to DNS wire format 275 | /// Example: "example.com" -> [0x07 "example" 0x03 "com" 0x00] 276 | ubyte[] parseDNSName(string name) 277 | { 278 | name = name.strip(); 279 | 280 | // Empty name is just a zero byte 281 | if (name.length == 0) 282 | return [0x00]; 283 | 284 | // Split into labels 285 | auto labels = name.split("."); 286 | 287 | ubyte[] result; 288 | foreach (label; labels) 289 | { 290 | enforce(label.length > 0, "DNS label cannot be empty"); 291 | enforce(label.length <= 63, "DNS label cannot exceed 63 characters"); 292 | 293 | // Add length byte 294 | result ~= cast(ubyte)label.length; 295 | 296 | // Add label characters 297 | result ~= cast(ubyte[])label; 298 | } 299 | 300 | // Add terminating zero byte 301 | result ~= 0x00; 302 | 303 | return result; 304 | } 305 | 306 | /// Parse DNS name with compression support (RFC 1035 section 4.1.4) 307 | /// Returns: (parsed name, bytes consumed) 308 | /// Compression pointers reference offsets within the full data buffer 309 | private Tuple!(string, size_t) parseDNSNameWithCompression(in ubyte[] data, size_t offset, in ubyte[] fullData) 310 | { 311 | 312 | string[] labels; 313 | size_t pos = offset; 314 | size_t[] visited; // Track visited offsets to detect loops 315 | 316 | while (pos < data.length) 317 | { 318 | // Check for compression pointer (top 2 bits = 11) 319 | if ((data[pos] & 0xC0) == 0xC0) 320 | { 321 | // Compression pointer: 2 bytes, top 2 bits are 11, rest is offset 322 | enforce(pos + 1 < data.length, "Truncated compression pointer"); 323 | 324 | ushort pointer = ((data[pos] & 0x3F) << 8) | data[pos + 1]; 325 | 326 | // Prevent infinite loops 327 | enforce(!visited.canFind(pointer), "Circular compression pointer detected"); 328 | visited ~= pointer; 329 | 330 | // Follow the pointer within fullData 331 | enforce(pointer < fullData.length, "Compression pointer out of bounds"); 332 | 333 | // Recursively parse from the pointer location 334 | auto result = parseDNSNameWithCompression(fullData, pointer, fullData); 335 | labels ~= result[0].split(".").filter!(l => l.length > 0).array; 336 | 337 | // Compression pointer consumes 2 bytes and ends the name 338 | return tuple(labels.join("."), pos - offset + 2); 339 | } 340 | 341 | ubyte len = data[pos++]; 342 | 343 | // Zero-length label marks end of name 344 | if (len == 0) 345 | break; 346 | 347 | // Ensure we have enough bytes for this label 348 | enforce(pos + len <= data.length, "DNS name label extends past end of data"); 349 | 350 | // Extract label 351 | labels ~= cast(string)data[pos .. pos + len]; 352 | pos += len; 353 | } 354 | 355 | return tuple(labels.join("."), pos - offset); 356 | } 357 | 358 | /// Format array of DNS names to wire format (RFC 3397) 359 | /// Returns concatenated DNS names in wire format (uncompressed for simplicity) 360 | ubyte[] formatDomainSearchList(string[] domains) 361 | { 362 | ubyte[] result; 363 | 364 | foreach (domain; domains) 365 | { 366 | result ~= parseDNSName(domain); 367 | } 368 | 369 | return result; 370 | } 371 | 372 | /// Parse domain search list from wire format (RFC 3397) 373 | /// Handles DNS compression pointers 374 | string[] parseDomainSearchList(in ubyte[] bytes) 375 | { 376 | 377 | string[] domains; 378 | size_t pos = 0; 379 | 380 | while (pos < bytes.length) 381 | { 382 | auto result = parseDNSNameWithCompression(bytes, pos, bytes); 383 | string domain = result[0]; 384 | size_t consumed = result[1]; 385 | 386 | if (domain.length > 0) 387 | domains ~= domain; 388 | 389 | pos += consumed; 390 | 391 | // Safety check to prevent infinite loops 392 | enforce(consumed > 0, "DNS name parsing made no progress"); 393 | } 394 | 395 | return domains; 396 | } 397 | 398 | // ============================================================================ 399 | // User Class Format Helpers (RFC 3004) 400 | // ============================================================================ 401 | 402 | /// Format User Class (array of length-prefixed strings) to array of strings 403 | /// RFC 3004 format: [len1][data1][len2][data2]... 404 | /// Example: [0x05 "class" 0x04 "test"] -> ["class", "test"] 405 | string[] formatUserClass(in ubyte[] bytes) 406 | { 407 | string[] result; 408 | size_t i = 0; 409 | 410 | while (i < bytes.length) 411 | { 412 | // Read length byte 413 | enforce(i < bytes.length, "User Class truncated: missing length byte"); 414 | ubyte len = bytes[i++]; 415 | 416 | // Read data bytes 417 | enforce(i + len <= bytes.length, "User Class truncated: not enough bytes for class data"); 418 | result ~= cast(string)bytes[i .. i + len]; 419 | i += len; 420 | } 421 | 422 | return result; 423 | } 424 | 425 | /// Parse array of strings to User Class format (length-prefixed strings) 426 | /// Example: ["class", "test"] -> [0x05 "class" 0x04 "test"] 427 | ubyte[] parseUserClass(string[] classes) 428 | { 429 | ubyte[] result; 430 | 431 | foreach (cls; classes) 432 | { 433 | enforce(cls.length <= 255, "User Class string too long (max 255 bytes)"); 434 | 435 | // Add length byte 436 | result ~= cast(ubyte)cls.length; 437 | 438 | // Add data bytes 439 | result ~= cast(ubyte[])cls; 440 | } 441 | 442 | return result; 443 | } 444 | -------------------------------------------------------------------------------- /source/dhcptest/options.d: -------------------------------------------------------------------------------- 1 | module dhcptest.options; 2 | 3 | import std.algorithm; 4 | import std.array; 5 | import std.ascii; 6 | import std.conv; 7 | import std.exception : enforce; 8 | import std.format; 9 | import std.range; 10 | import std.string; 11 | import std.traits; 12 | 13 | import dhcptest.formats; 14 | 15 | enum DHCPOptionType : ubyte 16 | { 17 | dhcpMessageType = 53, 18 | parameterRequestList = 55, 19 | } 20 | 21 | enum DHCPMessageType : ubyte 22 | { 23 | discover = 1, 24 | offer, 25 | request, 26 | decline, 27 | ack, 28 | nak, 29 | release, 30 | inform 31 | } 32 | 33 | enum NETBIOSNodeType : ubyte 34 | { 35 | bNode = 1, 36 | pNode = 2, 37 | mMode = 4, 38 | hNode = 8 39 | } 40 | enum NETBIOSNodeTypeChars = "BPMH"; 41 | 42 | /// Processor Architecture Types (RFC 4578, RFC 5970) 43 | /// Used in DHCP Option 93 (Client System Architecture Type) 44 | enum ProcessorArchitecture : ushort 45 | { 46 | x86BiosInt13h = 0, // x86 BIOS 47 | necPC98 = 1, // NEC/PC98 (DEPRECATED) 48 | ia64 = 2, // Itanium 49 | decAlpha = 3, // DEC Alpha (DEPRECATED) 50 | arcX86 = 4, // Arc x86 (DEPRECATED) 51 | intelLeanClient = 5, // Intel Lean Client (DEPRECATED) 52 | x86Uefi = 6, // x86 UEFI 53 | x64Uefi = 7, // x64 UEFI 54 | efiXscale = 8, // EFI Xscale (DEPRECATED) 55 | ebc = 9, // EFI Byte Code 56 | arm32Uefi = 10, // ARM 32-bit UEFI 57 | arm64Uefi = 11, // ARM 64-bit UEFI 58 | powerPCOpenFirmware = 12, // PowerPC Open Firmware 59 | powerPCEpapr = 13, // PowerPC ePAPR 60 | powerOpalV3 = 14, // POWER OPAL v3 61 | x86UefiHttp = 15, // x86 UEFI HTTP 62 | x64UefiHttp = 16, // x64 UEFI HTTP 63 | ebcHttp = 17, // EBC HTTP 64 | arm32UefiHttp = 18, // ARM 32-bit UEFI HTTP 65 | arm64UefiHttp = 19, // ARM 64-bit UEFI HTTP 66 | pcBiosHttp = 20, // PC BIOS HTTP 67 | arm32Uboot = 21, // ARM 32-bit u-boot 68 | arm64Uboot = 22, // ARM 64-bit u-boot 69 | arm32UbootHttp = 23, // ARM 32-bit u-boot HTTP 70 | arm64UbootHttp = 24, // ARM 64-bit u-boot HTTP 71 | riscV32Uefi = 25, // RISC-V 32-bit UEFI 72 | riscV32UefiHttp = 26, // RISC-V 32-bit UEFI HTTP 73 | riscV64Uefi = 27, // RISC-V 64-bit UEFI 74 | riscV64UefiHttp = 28, // RISC-V 64-bit UEFI HTTP 75 | riscV128Uefi = 29, // RISC-V 128-bit UEFI 76 | riscV128UefiHttp = 30, // RISC-V 128-bit UEFI HTTP 77 | s390Basic = 31, // s390 Basic 78 | s390Extended = 32, // s390 Extended 79 | mips32Uefi = 33, // MIPS 32-bit UEFI 80 | mips64Uefi = 34, // MIPS 64-bit UEFI 81 | sunwayUefi = 35, // Sunway 32-bit UEFI 82 | sunway64Uefi = 36, // Sunway 64-bit UEFI 83 | loongArch32Uefi = 37, // LoongArch 32-bit UEFI 84 | loongArch32UefiHttp = 38, // LoongArch 32-bit UEFI HTTP 85 | loongArch64Uefi = 39, // LoongArch 64-bit UEFI 86 | loongArch64UefiHttp = 40, // LoongArch 64-bit UEFI HTTP 87 | armRpiboot = 41, // ARM rpiboot 88 | } 89 | 90 | /// Format processor architecture type as human-readable string 91 | string formatProcessorArchitecture(ushort value) 92 | { 93 | // Check if it's a known enum value 94 | foreach (member; EnumMembers!ProcessorArchitecture) 95 | { 96 | if (value == member) 97 | return member.to!string; 98 | } 99 | 100 | // Unknown value - return numeric representation 101 | return value.to!string; 102 | } 103 | 104 | /// Parse processor architecture type from string 105 | ushort parseProcessorArchitecture(string s) 106 | { 107 | s = s.strip(); 108 | 109 | // Try to parse as enum member name 110 | foreach (member; EnumMembers!ProcessorArchitecture) 111 | { 112 | if (s.toLower() == member.to!string.toLower()) 113 | return member; 114 | } 115 | 116 | // Try to parse as numeric value 117 | return s.to!ushort; 118 | } 119 | 120 | struct DHCPOptionSpec 121 | { 122 | string name; 123 | OptionFormat format; 124 | } 125 | 126 | DHCPOptionSpec[ubyte] dhcpOptions; 127 | static this() 128 | { 129 | dhcpOptions = 130 | [ 131 | 0 : DHCPOptionSpec("Pad Option", OptionFormat.special), 132 | 1 : DHCPOptionSpec("Subnet Mask", OptionFormat.ip), 133 | 2 : DHCPOptionSpec("Time Offset", OptionFormat.duration), 134 | 3 : DHCPOptionSpec("Router Option", OptionFormat.ip), 135 | 4 : DHCPOptionSpec("Time Server Option", OptionFormat.ip), 136 | 5 : DHCPOptionSpec("Name Server Option", OptionFormat.ip), 137 | 6 : DHCPOptionSpec("Domain Name Server Option", OptionFormat.ip), 138 | 7 : DHCPOptionSpec("Log Server Option", OptionFormat.ip), 139 | 8 : DHCPOptionSpec("Cookie Server Option", OptionFormat.ip), 140 | 9 : DHCPOptionSpec("LPR Server Option", OptionFormat.ip), 141 | 10 : DHCPOptionSpec("Impress Server Option", OptionFormat.ip), 142 | 11 : DHCPOptionSpec("Resource Location Server Option", OptionFormat.ip), 143 | 12 : DHCPOptionSpec("Host Name Option", OptionFormat.str), 144 | 13 : DHCPOptionSpec("Boot File Size Option", OptionFormat.u16), 145 | 14 : DHCPOptionSpec("Merit Dump File", OptionFormat.str), 146 | 15 : DHCPOptionSpec("Domain Name", OptionFormat.str), 147 | 16 : DHCPOptionSpec("Swap Server", OptionFormat.ip), 148 | 17 : DHCPOptionSpec("Root Path", OptionFormat.str), 149 | 18 : DHCPOptionSpec("Extensions Path", OptionFormat.str), 150 | 19 : DHCPOptionSpec("IP Forwarding Enable/Disable Option", OptionFormat.boolean), 151 | 20 : DHCPOptionSpec("Non-Local Source Routing Enable/Disable Option", OptionFormat.boolean), 152 | 21 : DHCPOptionSpec("Policy Filter Option", OptionFormat.ip), 153 | 22 : DHCPOptionSpec("Maximum Datagram Reassembly Size", OptionFormat.u16), 154 | 23 : DHCPOptionSpec("Default IP Time-to-live", OptionFormat.u8), 155 | 24 : DHCPOptionSpec("Path MTU Aging Timeout Option", OptionFormat.u32), 156 | 25 : DHCPOptionSpec("Path MTU Plateau Table Option", OptionFormat.u16), 157 | 26 : DHCPOptionSpec("Interface MTU Option", OptionFormat.u16), 158 | 27 : DHCPOptionSpec("All Subnets are Local Option", OptionFormat.boolean), 159 | 28 : DHCPOptionSpec("Broadcast Address Option", OptionFormat.ip), 160 | 29 : DHCPOptionSpec("Perform Mask Discovery Option", OptionFormat.boolean), 161 | 30 : DHCPOptionSpec("Mask Supplier Option", OptionFormat.boolean), 162 | 31 : DHCPOptionSpec("Perform Router Discovery Option", OptionFormat.boolean), 163 | 32 : DHCPOptionSpec("Router Solicitation Address Option", OptionFormat.ip), 164 | 33 : DHCPOptionSpec("Static Route Option", OptionFormat.ip), 165 | 34 : DHCPOptionSpec("Trailer Encapsulation Option", OptionFormat.boolean), 166 | 35 : DHCPOptionSpec("ARP Cache Timeout Option", OptionFormat.u32), 167 | 36 : DHCPOptionSpec("Ethernet Encapsulation Option", OptionFormat.boolean), 168 | 37 : DHCPOptionSpec("TCP Default TTL Option", OptionFormat.u8), 169 | 38 : DHCPOptionSpec("TCP Keepalive Interval Option", OptionFormat.u32), 170 | 39 : DHCPOptionSpec("TCP Keepalive Garbage Option", OptionFormat.boolean), 171 | 40 : DHCPOptionSpec("Network Information Service Domain Option", OptionFormat.str), 172 | 41 : DHCPOptionSpec("Network Information Servers Option", OptionFormat.ip), 173 | 42 : DHCPOptionSpec("Network Time Protocol Servers Option", OptionFormat.ip), 174 | 43 : DHCPOptionSpec("Vendor Specific Information", OptionFormat.vendorSpecificInformation), 175 | 44 : DHCPOptionSpec("NetBIOS over TCP/IP Name Server Option", OptionFormat.ip), 176 | 45 : DHCPOptionSpec("NetBIOS over TCP/IP Datagram Distribution Server Option", OptionFormat.ip), 177 | 46 : DHCPOptionSpec("NetBIOS over TCP/IP Node Type Option", OptionFormat.netbiosNodeType), 178 | 47 : DHCPOptionSpec("NetBIOS over TCP/IP Scope Option", OptionFormat.str), 179 | 48 : DHCPOptionSpec("X Window System Font Server Option", OptionFormat.ip), 180 | 49 : DHCPOptionSpec("X Window System Display Manager Option", OptionFormat.ip), 181 | 50 : DHCPOptionSpec("Requested IP Address", OptionFormat.ip), 182 | 51 : DHCPOptionSpec("IP Address Lease Time", OptionFormat.duration), 183 | 52 : DHCPOptionSpec("Option Overload", OptionFormat.clientIdentifier), 184 | 53 : DHCPOptionSpec("DHCP Message Type", OptionFormat.dhcpMessageType), 185 | 54 : DHCPOptionSpec("Server Identifier", OptionFormat.ip), 186 | 55 : DHCPOptionSpec("Parameter Request List", OptionFormat.dhcpOptionType), 187 | 56 : DHCPOptionSpec("Message", OptionFormat.str), 188 | 57 : DHCPOptionSpec("Maximum DHCP Message Size", OptionFormat.u16), 189 | 58 : DHCPOptionSpec("Renewal (T1) Time Value", OptionFormat.duration), 190 | 59 : DHCPOptionSpec("Rebinding (T2) Time Value", OptionFormat.duration), 191 | 60 : DHCPOptionSpec("Vendor class identifier", OptionFormat.str), 192 | 61 : DHCPOptionSpec("Client-identifier", OptionFormat.u8), 193 | 64 : DHCPOptionSpec("Network Information Service+ Domain Option", OptionFormat.str), 194 | 65 : DHCPOptionSpec("Network Information Service+ Servers Option", OptionFormat.ip), 195 | 66 : DHCPOptionSpec("TFTP server name", OptionFormat.str), 196 | 67 : DHCPOptionSpec("Bootfile name", OptionFormat.str), 197 | 68 : DHCPOptionSpec("Mobile IP Home Agent option", OptionFormat.ip), 198 | 69 : DHCPOptionSpec("Simple Mail Transport Protocol (SMTP) Server Option", OptionFormat.ip), 199 | 70 : DHCPOptionSpec("Post Office Protocol (POP3) Server Option", OptionFormat.ip), 200 | 71 : DHCPOptionSpec("Network News Transport Protocol (NNTP) Server Option", OptionFormat.ip), 201 | 72 : DHCPOptionSpec("Default World Wide Web (WWW) Server Option", OptionFormat.ip), 202 | 73 : DHCPOptionSpec("Default Finger Server Option", OptionFormat.ip), 203 | 74 : DHCPOptionSpec("Default Internet Relay Chat (IRC) Server Option", OptionFormat.ip), 204 | 75 : DHCPOptionSpec("StreetTalk Server Option", OptionFormat.ip), 205 | 76 : DHCPOptionSpec("StreetTalk Directory Assistance (STDA) Server Option", OptionFormat.ip), 206 | // RFC 3004 - The User Class Option for DHCP 207 | // Format: array of length-prefixed strings [len1][data1][len2][data2]... 208 | // Each user class is a separate opaque identifier configured by the administrator 209 | 77 : DHCPOptionSpec("User Class", OptionFormat.userClass), 210 | 80 : DHCPOptionSpec("Rapid Commit", OptionFormat.zeroLength), 211 | // RFC 4702 - The DHCP Client FQDN Option 212 | // Format: flags (1 byte) + rcode1 (1 byte, deprecated) + rcode2 (1 byte, deprecated) + domain name (DNS wire format) 213 | // Used by clients to communicate their fully qualified domain name to DHCP servers 214 | // and to negotiate which party (client or server) should perform DNS updates 215 | 81 : DHCPOptionSpec("Client FQDN", OptionFormat.clientFQDN), 216 | 82 : DHCPOptionSpec("Relay Agent Information", OptionFormat.relayAgent), 217 | // RFC 4578 / RFC 5970 - Client System Architecture Type 218 | // See IANA Processor Architecture Types registry for full list 219 | // Common values: 0=x86 BIOS, 6=x86 UEFI, 7=x64 UEFI, 10=ARM32 UEFI, 11=ARM64 UEFI 220 | 93 : DHCPOptionSpec("Client System Architecture Type", OptionFormat.processorArchitecture), 221 | // RFC 5859 - TFTP Server Address Option for DHCPv4 222 | // List of IPv4 addresses for TFTP/configuration servers (Cisco VoIP phones, etc.) 223 | // Servers should be listed in order of preference 224 | 150 : DHCPOptionSpec("TFTP Server Address", OptionFormat.ips), 225 | 100 : DHCPOptionSpec("PCode", OptionFormat.str), 226 | 101 : DHCPOptionSpec("TCode", OptionFormat.str), 227 | 108 : DHCPOptionSpec("IPv6-Only Preferred", OptionFormat.u32), 228 | 114 : DHCPOptionSpec("DHCP Captive-Portal", OptionFormat.str), 229 | 116 : DHCPOptionSpec("Auto Config", OptionFormat.boolean), 230 | 118 : DHCPOptionSpec("Subnet Selection", OptionFormat.ip), 231 | // RFC 3397 - Dynamic Host Configuration Protocol (DHCP) Domain Search Option 232 | // Format: Array of DNS domain names with compression support (RFC 1035) 233 | // Used for DNS search domain list (e.g., "example.com", "test.org") 234 | // Some OS versions prefer this over option 15 for default domain 235 | 119 : DHCPOptionSpec("Domain Search", OptionFormat.domainSearch), 236 | 121 : DHCPOptionSpec("Classless Static Route Option", OptionFormat.classlessStaticRoute), 237 | 249 : DHCPOptionSpec("Microsoft Classless Static Route", OptionFormat.classlessStaticRoute), 238 | 252 : DHCPOptionSpec("Web Proxy Auto-Discovery", OptionFormat.str), 239 | 255 : DHCPOptionSpec("End Option", OptionFormat.special), 240 | ]; 241 | } 242 | 243 | string formatDHCPOptionType(DHCPOptionType type) 244 | { 245 | return format("%3d (%s)", cast(ubyte)type, dhcpOptions.get(type, DHCPOptionSpec("Unknown")).name); 246 | } 247 | 248 | DHCPOptionType parseDHCPOptionType(string type) 249 | { 250 | if (type.isNumeric) 251 | return cast(DHCPOptionType)type.to!ubyte; 252 | foreach (opt, spec; dhcpOptions) 253 | if (!icmp(spec.name, type)) 254 | return cast(DHCPOptionType)opt; 255 | throw new Exception("Unknown DHCP option type: " ~ type); 256 | } 257 | 258 | unittest 259 | { 260 | import dhcptest.formats; 261 | 262 | // Test Option 93 - Client System Architecture Type (RFC 4578/5970) 263 | // Displays architecture names instead of numbers for better readability 264 | 265 | assert(dhcpOptions[93].name == "Client System Architecture Type"); 266 | assert(dhcpOptions[93].format == OptionFormat.processorArchitecture); 267 | 268 | // Test parsing by architecture name - x86 BIOS (Type 0) 269 | auto biosx86 = parseOption("x86BiosInt13h", OptionFormat.processorArchitecture); 270 | assert(biosx86 == [0x00, 0x00]); 271 | assert(formatValue(biosx86, OptionFormat.processorArchitecture) == "x86BiosInt13h"); 272 | 273 | // Test parsing by numeric value - x86 UEFI (Type 6) 274 | auto efi32 = parseOption("6", OptionFormat.processorArchitecture); 275 | assert(efi32 == [0x00, 0x06]); 276 | assert(formatValue(efi32, OptionFormat.processorArchitecture) == "x86Uefi"); 277 | 278 | // Test parsing by name - x64 UEFI (Type 7) 279 | auto efi64 = parseOption("x64Uefi", OptionFormat.processorArchitecture); 280 | assert(efi64 == [0x00, 0x07]); 281 | assert(formatValue(efi64, OptionFormat.processorArchitecture) == "x64Uefi"); 282 | 283 | // Test EBC (Type 9) 284 | auto ebc = parseOption("ebc", OptionFormat.processorArchitecture); 285 | assert(ebc == [0x00, 0x09]); 286 | assert(formatValue(ebc, OptionFormat.processorArchitecture) == "ebc"); 287 | 288 | // Test ARM64 UEFI (Type 11) - popular for modern ARM systems 289 | auto arm64 = parseOption("arm64Uefi", OptionFormat.processorArchitecture); 290 | assert(arm64 == [0x00, 0x0b]); 291 | assert(formatValue(arm64, OptionFormat.processorArchitecture) == "arm64Uefi"); 292 | 293 | // Test RISC-V 64-bit UEFI (Type 27) 294 | auto riscv64 = parseOption("27", OptionFormat.processorArchitecture); 295 | assert(riscv64 == [0x00, 0x1b]); 296 | assert(formatValue(riscv64, OptionFormat.processorArchitecture) == "riscV64Uefi"); 297 | 298 | // Test unknown architecture (e.g., 255) - should return numeric string 299 | auto unknown = parseOption("255", OptionFormat.processorArchitecture); 300 | assert(unknown == [0x00, 0xFF]); 301 | assert(formatValue(unknown, OptionFormat.processorArchitecture) == "255"); 302 | } 303 | 304 | unittest 305 | { 306 | // Test Option 150 - TFTP Server Address (RFC 5859) 307 | // Used by Cisco and Polycom VoIP phones 308 | 309 | assert(dhcpOptions[150].name == "TFTP Server Address"); 310 | assert(dhcpOptions[150].format == OptionFormat.ips); 311 | 312 | // Test single TFTP server 313 | auto single = parseOption("192.168.1.10", OptionFormat.ips); 314 | assert(single == [192, 168, 1, 10]); 315 | assert(formatValue(single, OptionFormat.ips) == "[192.168.1.10]"); 316 | 317 | // Test multiple TFTP servers (redundancy) 318 | // Example: Primary and backup TFTP servers for VoIP phones 319 | auto multi = parseOption("[192.168.1.10, 192.168.1.11]", OptionFormat.ips); 320 | assert(multi == [192, 168, 1, 10, 192, 168, 1, 11]); 321 | assert(formatValue(multi, OptionFormat.ips) == "[192.168.1.10, 192.168.1.11]"); 322 | 323 | // Test roundtrip 324 | auto formatted = formatValue(multi, OptionFormat.ips); 325 | auto reparsed = parseOption(formatted, OptionFormat.ips); 326 | assert(reparsed == multi); 327 | } 328 | 329 | unittest 330 | { 331 | // Test Option 81 - Client FQDN (RFC 4702) 332 | // Used by DHCP clients to communicate their fully qualified domain name 333 | // and negotiate which party should perform DNS updates 334 | 335 | assert(dhcpOptions[81].name == "Client FQDN"); 336 | assert(dhcpOptions[81].format == OptionFormat.clientFQDN); 337 | 338 | // Test basic FQDN with typical flags 339 | // flags=1 (S bit set, server should update A record) 340 | // rcode1=0, rcode2=255 (deprecated fields, RFC-recommended values) 341 | // name="client.example.com" 342 | // DNS wire format: [0x06 "client" 0x07 "example" 0x03 "com" 0x00] 343 | auto basic = parseOption("flags=1, rcode1=0, rcode2=255, name=client.example.com", OptionFormat.clientFQDN); 344 | assert(basic == [0x01, 0x00, 0xFF, 0x06, 'c', 'l', 'i', 'e', 'n', 't', 0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00]); 345 | assert(formatValue(basic, OptionFormat.clientFQDN) == "flags=1, rcode1=0, rcode2=255, name=client.example.com"); 346 | 347 | // Test empty domain name (client requesting server-provided name) 348 | auto empty = parseOption("flags=0, rcode1=0, rcode2=255, name=", OptionFormat.clientFQDN); 349 | assert(empty == [0x00, 0x00, 0xFF, 0x00]); 350 | assert(formatValue(empty, OptionFormat.clientFQDN) == "flags=0, rcode1=0, rcode2=255, name=\"\""); 351 | 352 | // Test roundtrip 353 | auto formatted = formatValue(basic, OptionFormat.clientFQDN); 354 | auto reparsed = parseOption(formatted, OptionFormat.clientFQDN); 355 | assert(reparsed == basic); 356 | } 357 | 358 | unittest 359 | { 360 | // Test Option 77 - User Class (RFC 3004) 361 | // Used to identify client type/category for DHCP policy selection 362 | // Format: array of length-prefixed strings 363 | 364 | assert(dhcpOptions[77].name == "User Class"); 365 | assert(dhcpOptions[77].format == OptionFormat.userClass); 366 | 367 | // Test single user class 368 | // "myuserclass" -> [0x0b "myuserclass"] 369 | auto single = parseOption("myuserclass", OptionFormat.userClass); 370 | assert(single == [0x0b, 'm', 'y', 'u', 's', 'e', 'r', 'c', 'l', 'a', 's', 's']); 371 | assert(formatValue(single, OptionFormat.userClass) == "[myuserclass]"); 372 | 373 | // Test multiple user classes (RFC 3004 compliant) 374 | // ["class1", "class2"] -> [0x06 "class1" 0x06 "class2"] 375 | auto multi = parseOption("[\"class1\", \"class2\"]", OptionFormat.userClass); 376 | assert(multi == [0x06, 'c', 'l', 'a', 's', 's', '1', 0x06, 'c', 'l', 'a', 's', 's', '2']); 377 | assert(formatValue(multi, OptionFormat.userClass) == "[class1, class2]"); 378 | 379 | // Test roundtrip 380 | auto formatted = formatValue(multi, OptionFormat.userClass); 381 | auto reparsed = parseOption(formatted, OptionFormat.userClass); 382 | assert(reparsed == multi); 383 | } 384 | 385 | unittest 386 | { 387 | // Test Option 119 - Domain Search (RFC 3397) 388 | // Used for DNS search domain list 389 | // Format: Array of DNS domain names with optional compression 390 | 391 | assert(dhcpOptions[119].name == "Domain Search"); 392 | assert(dhcpOptions[119].format == OptionFormat.domainSearch); 393 | 394 | // Test single domain 395 | // "example.com" -> [0x07 "example" 0x03 "com" 0x00] 396 | auto single = parseOption("example.com", OptionFormat.domainSearch); 397 | assert(single == [0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00]); 398 | assert(formatValue(single, OptionFormat.domainSearch) == "[example.com]"); 399 | 400 | // Test multiple domains (uncompressed format) 401 | // ["eng.apple.com", "marketing.apple.com"] 402 | auto multi = parseOption("[\"eng.apple.com\", \"marketing.apple.com\"]", OptionFormat.domainSearch); 403 | // Uncompressed: eng.apple.com. + marketing.apple.com. 404 | assert(multi == [ 405 | 0x03, 'e', 'n', 'g', 0x05, 'a', 'p', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00, 406 | 0x09, 'm', 'a', 'r', 'k', 'e', 't', 'i', 'n', 'g', 0x05, 'a', 'p', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00 407 | ]); 408 | assert(formatValue(multi, OptionFormat.domainSearch) == "[eng.apple.com, marketing.apple.com]"); 409 | 410 | // Test compressed format parsing (from RFC 3397 example) 411 | // "eng.apple.com." + "marketing" + pointer to offset 4 (apple.com.) 412 | auto compressed = cast(ubyte[])[ 413 | 0x03, 'e', 'n', 'g', 0x05, 'a', 'p', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00, // eng.apple.com. 414 | 0x09, 'm', 'a', 'r', 'k', 'e', 't', 'i', 'n', 'g', 0xC0, 0x04 // marketing + pointer to offset 4 415 | ]; 416 | assert(formatValue(compressed, OptionFormat.domainSearch) == "[eng.apple.com, marketing.apple.com]"); 417 | 418 | // Test roundtrip (will be uncompressed) 419 | auto formatted = formatValue(multi, OptionFormat.domainSearch); 420 | auto reparsed = parseOption(formatted, OptionFormat.domainSearch); 421 | assert(formatValue(reparsed, OptionFormat.domainSearch) == formatted); 422 | } 423 | -------------------------------------------------------------------------------- /source/dhcptest/formats/formatting.d: -------------------------------------------------------------------------------- 1 | /** 2 | * Formatting implementation for DHCP option values. 3 | * 4 | * This module provides functions to convert binary DHCP option data 5 | * into human-readable DSL string format. 6 | */ 7 | module dhcptest.formats.formatting; 8 | 9 | import std.algorithm; 10 | import std.array; 11 | import std.conv; 12 | import std.datetime; 13 | import std.exception : enforce; 14 | import std.format; 15 | import std.range; 16 | import std.string; 17 | 18 | import dhcptest.formats.types; 19 | import dhcptest.options : DHCPOptionType, formatDHCPOptionType, dhcpOptions, DHCPOptionSpec; 20 | 21 | // Import network byte order functions 22 | version (Windows) 23 | static if (__VERSION__ >= 2067) 24 | import core.sys.windows.winsock2 : ntohs, ntohl; 25 | else 26 | import std.c.windows.winsock : ntohs, ntohl; 27 | else 28 | version (Posix) 29 | import core.sys.posix.netdb : ntohs, ntohl; 30 | 31 | // ============================================================================ 32 | // OptionFormatter - struct-based formatter (similar to OptionParser) 33 | // ============================================================================ 34 | 35 | /// Formatter for DHCP option values to DSL string format 36 | /// Template parameter Out specifies the output sink type (typically an appender) 37 | struct OptionFormatter(Out) 38 | { 39 | Out output; /// Output sink 40 | Syntax syntax; /// Output syntax style (minimal or json) 41 | void delegate(string) onWarning; /// Optional callback for formatting warnings 42 | 43 | /// Format a value as DSL string to the output sink 44 | void formatValue(const ubyte[] bytes, OptionFormat type) 45 | { 46 | final switch (type) 47 | { 48 | // Special types (not formattable) 49 | case OptionFormat.unknown: 50 | // Format as hex for unknown types 51 | output.put(maybeAscii(bytes)); 52 | break; 53 | case OptionFormat.special: 54 | throw new Exception("Cannot format special format"); 55 | 56 | case OptionFormat.str: 57 | case OptionFormat.fullString: 58 | formatScalar(cast(string)bytes); 59 | break; 60 | 61 | case OptionFormat.hex: 62 | final switch (syntax) 63 | { 64 | case Syntax.json: 65 | // JSON mode: hex values must be quoted strings 66 | auto hexStr = bytes.map!(b => format("%02X", b)).join(" "); 67 | formatScalar(hexStr); 68 | break; 69 | case Syntax.plain: 70 | // Plain mode: hex without ASCII decoration (machine-readable) 71 | output.put(bytes.map!(b => format("%02X", b)).join(" ")); 72 | break; 73 | case Syntax.verbose: 74 | // Verbose mode: hex with ASCII decoration 75 | output.put(maybeAscii(bytes)); 76 | break; 77 | } 78 | break; 79 | 80 | case OptionFormat.ip: 81 | enforce(bytes.length == 4, "IP address must be 4 bytes"); 82 | formatScalar(format("%(%d.%)", bytes)); 83 | break; 84 | 85 | case OptionFormat.boolean: 86 | enforce(bytes.length == 1, "Boolean must be 1 byte"); 87 | formatScalar(bytes[0] ? "true" : "false"); 88 | break; 89 | 90 | case OptionFormat.u8: 91 | enforce(bytes.length == 1, "u8 must be 1 byte"); 92 | formatNumber(bytes[0]); 93 | break; 94 | 95 | case OptionFormat.u16: 96 | enforce(bytes.length == 2, "u16 must be 2 bytes"); 97 | auto value = ntohs(*cast(ushort*)bytes.ptr); 98 | formatNumber(value); 99 | break; 100 | 101 | case OptionFormat.u32: 102 | enforce(bytes.length == 4, "u32 must be 4 bytes"); 103 | auto value = ntohl(*cast(uint*)bytes.ptr); 104 | formatNumber(value); 105 | break; 106 | 107 | case OptionFormat.duration: 108 | enforce(bytes.length == 4, "time must be 4 bytes"); 109 | auto value = ntohl(*cast(uint*)bytes.ptr); 110 | final switch (syntax) 111 | { 112 | case Syntax.json: 113 | // JSON mode: output as plain number 114 | formatNumber(value); 115 | break; 116 | case Syntax.plain: 117 | // Plain mode: just the number, no duration comment 118 | formatNumber(value); 119 | break; 120 | case Syntax.verbose: 121 | // Verbose mode: number with human-readable duration as comment 122 | formatNumber(value); 123 | formatComment(value.seconds.to!string); 124 | break; 125 | } 126 | break; 127 | 128 | case OptionFormat.dhcpMessageType: 129 | enforce(bytes.length == 1, "dhcpMessageType must be 1 byte"); 130 | formatScalar((cast(DHCPMessageType)bytes[0]).to!string); 131 | break; 132 | 133 | case OptionFormat.dhcpOptionType: 134 | enforce(bytes.length == 1, "dhcpOptionType must be 1 byte"); 135 | // Format as number with description as comment 136 | // In JSON mode: outputs just the number (e.g., 3) 137 | // In minimal mode: outputs number with description (e.g., 3 (Router Option)) 138 | auto optionType = cast(DHCPOptionType)bytes[0]; 139 | // Get the option name from the table, or use generic name 140 | auto spec = dhcpOptions.get(optionType, DHCPOptionSpec.init); 141 | string optionName = spec.name.length > 0 ? spec.name : format("Option %d", bytes[0]); 142 | formatNumber(bytes[0]); 143 | formatComment(optionName); 144 | break; 145 | 146 | case OptionFormat.netbiosNodeType: 147 | enforce(bytes.length == 1, "netbiosNodeType must be 1 byte"); 148 | // Format as character string (e.g., "B", "BP", "M") 149 | string result; 150 | foreach (i; 0 .. NETBIOSNodeTypeChars.length) 151 | if ((1 << i) & bytes[0]) 152 | result ~= NETBIOSNodeTypeChars[i]; 153 | formatScalar(result); 154 | break; 155 | 156 | case OptionFormat.processorArchitecture: 157 | enforce(bytes.length == 2, "processorArchitecture must be 2 bytes"); 158 | // Parse as u16 in network byte order 159 | ushort value = (cast(ushort)bytes[0] << 8) | bytes[1]; 160 | formatScalar(formatProcessorArchitecture(value)); 161 | break; 162 | 163 | case OptionFormat.zeroLength: 164 | enforce(bytes.length == 0, "Zero-length must be empty"); 165 | formatScalar("present"); 166 | break; 167 | 168 | case OptionFormat.ips: 169 | formatArray(bytes, OptionFormat.ip, 4); 170 | break; 171 | 172 | case OptionFormat.u8s: 173 | formatArray(bytes, OptionFormat.u8, 1); 174 | break; 175 | 176 | case OptionFormat.u16s: 177 | formatArray(bytes, OptionFormat.u16, 2); 178 | break; 179 | 180 | case OptionFormat.u32s: 181 | formatArray(bytes, OptionFormat.u32, 4); 182 | break; 183 | 184 | case OptionFormat.durations: 185 | formatArray(bytes, OptionFormat.duration, 4); 186 | break; 187 | 188 | case OptionFormat.dhcpOptionTypes: 189 | formatArray(bytes, OptionFormat.dhcpOptionType, 1); 190 | break; 191 | 192 | case OptionFormat.classlessStaticRoute: 193 | auto routes = formatClasslessStaticRoute(bytes); 194 | final switch (syntax) 195 | { 196 | case Syntax.json: 197 | // JSON mode: format as array of [subnet, router] pairs 198 | output.put('['); 199 | foreach (i, route; routes) 200 | { 201 | if (i > 0) 202 | output.put(", "); 203 | // Split "subnet/mask -> router" into two parts 204 | auto parts = route.split(" -> "); 205 | enforce(parts.length == 2, "Invalid route format"); 206 | output.put('['); 207 | formatScalar(parts[0]); 208 | output.put(", "); 209 | formatScalar(parts[1]); 210 | output.put(']'); 211 | } 212 | output.put(']'); 213 | break; 214 | case Syntax.plain: 215 | // Plain mode: arrow format like verbose (machine-readable) 216 | output.put(routes.join(", ")); 217 | break; 218 | case Syntax.verbose: 219 | // Verbose mode: use arrow format "subnet/mask -> router, ..." 220 | output.put(routes.join(", ")); 221 | break; 222 | } 223 | break; 224 | 225 | case OptionFormat.clientIdentifier: 226 | // Format as "type=N, clientIdentifier=hex" 227 | formatClientIdentifier(bytes); 228 | break; 229 | 230 | case OptionFormat.clientFQDN: 231 | // Format as "flags=N, rcode1=N, rcode2=N, name=domain" 232 | formatClientFQDN(bytes); 233 | break; 234 | 235 | case OptionFormat.userClass: 236 | // Format as array of strings 237 | formatUserClassArray(bytes); 238 | break; 239 | 240 | case OptionFormat.domainSearch: 241 | // Format as array of domain names 242 | formatDomainSearchArray(bytes); 243 | break; 244 | 245 | case OptionFormat.relayAgent: 246 | formatTLVList!RelayAgentSuboption(bytes); 247 | break; 248 | 249 | case OptionFormat.vendorSpecificInformation: 250 | formatTLVList!VendorSpecificSuboption(bytes); 251 | break; 252 | 253 | case OptionFormat.option: 254 | // Decode: [optionType][value...] 255 | enforce(bytes.length >= 1, "Option must have at least 1 byte (type)"); 256 | auto opt = cast(DHCPOptionType)bytes[0]; 257 | auto valueBytes = bytes[1 .. $]; 258 | 259 | // Get default format for this option 260 | auto defaultFmt = dhcpOptions.get(opt, DHCPOptionSpec.init).format; 261 | 262 | // Format as field: optionName=value 263 | formatField(formatDHCPOptionType(opt), valueBytes, defaultFmt); 264 | 265 | break; 266 | } 267 | } 268 | 269 | /// Format a field as name=value or name[format]=value (minimal) 270 | /// or "name": value or "name[format]": value (JSON) 271 | /// If formatOverride differs from defaultFormat, include the format in brackets 272 | /// If formatting fails, falls back to hex format and emits a warning 273 | private void formatField( 274 | string name, 275 | const(ubyte)[] value, 276 | OptionFormat formatUsed, 277 | OptionFormat defaultFormat = OptionFormat.unknown) 278 | { 279 | // Try to format the value, fall back to hex on error 280 | try 281 | { 282 | // Format field name 283 | final switch (syntax) 284 | { 285 | case Syntax.json: 286 | // JSON mode: quoted field name 287 | output.put('"'); 288 | output.put(name); 289 | // Show format override in the quoted name 290 | if (defaultFormat != OptionFormat.unknown && formatUsed != defaultFormat) 291 | { 292 | output.put('['); 293 | output.put(formatUsed.to!string); 294 | output.put(']'); 295 | } 296 | output.put('"'); 297 | output.put(':'); 298 | output.put(' '); 299 | break; 300 | case Syntax.plain: 301 | // Plain mode: unquoted like verbose 302 | output.put(name); 303 | if (defaultFormat != OptionFormat.unknown && formatUsed != defaultFormat) 304 | { 305 | output.put('['); 306 | output.put(formatUsed.to!string); 307 | output.put(']'); 308 | } 309 | output.put('='); 310 | break; 311 | case Syntax.verbose: 312 | // Verbose mode: unquoted field name 313 | output.put(name); 314 | // Show format override after the name 315 | if (defaultFormat != OptionFormat.unknown && formatUsed != defaultFormat) 316 | { 317 | output.put('['); 318 | output.put(formatUsed.to!string); 319 | output.put(']'); 320 | } 321 | output.put('='); 322 | break; 323 | } 324 | 325 | formatValue(value, formatUsed); 326 | } 327 | catch (Exception e) 328 | { 329 | // Emit warning if callback is set 330 | if (onWarning) 331 | onWarning(format("Error formatting field '%s' as %s: %s (falling back to hex)", 332 | name, formatUsed, e.msg)); 333 | 334 | // Fall back to hex format (never throws) 335 | formatField(name, value, OptionFormat.hex, defaultFormat); 336 | } 337 | } 338 | 339 | /// Format a numeric value (unquoted in both syntaxes) 340 | private void formatNumber(T : ulong)(T value) 341 | { 342 | output.put(value.to!string); 343 | } 344 | 345 | /// Format a scalar string value for DSL output 346 | private void formatScalar(string value) 347 | { 348 | bool needsQuoting; 349 | final switch (syntax) 350 | { 351 | case Syntax.json: 352 | // JSON mode: all string values must be quoted 353 | needsQuoting = true; 354 | break; 355 | case Syntax.plain: 356 | // Plain mode: only quote if necessary (like verbose) 357 | needsQuoting = value.length == 0; 358 | if (!needsQuoting) 359 | { 360 | foreach (ch; value) 361 | { 362 | if (isSpecialChar(ch) || isWhitespace(ch) || ch < 0x20 || ch > 0x7E) 363 | { 364 | needsQuoting = true; 365 | break; 366 | } 367 | } 368 | } 369 | break; 370 | case Syntax.verbose: 371 | // Verbose mode: only quote if necessary 372 | needsQuoting = value.length == 0; 373 | if (!needsQuoting) 374 | { 375 | foreach (ch; value) 376 | { 377 | // Need quoting if special char, whitespace, or non-printable 378 | if (isSpecialChar(ch) || isWhitespace(ch) || ch < 0x20 || ch > 0x7E) 379 | { 380 | needsQuoting = true; 381 | break; 382 | } 383 | } 384 | } 385 | break; 386 | } 387 | 388 | if (needsQuoting) 389 | { 390 | output.put('"'); 391 | formatEscapedString(value); 392 | output.put('"'); 393 | } 394 | else 395 | { 396 | output.put(value); 397 | } 398 | } 399 | 400 | /// Format an array using callback-based approach (similar to formatStruct) 401 | /// extractItems: returns all items as ubyte[][] 402 | /// getItemFormat: returns format for item at given index 403 | private void formatArray( 404 | scope ubyte[][] delegate() extractItems, 405 | scope OptionFormat delegate(size_t index) getItemFormat) 406 | { 407 | auto items = extractItems(); 408 | 409 | output.put('['); 410 | 411 | bool first = true; 412 | foreach (i, itemBytes; items) 413 | { 414 | if (!first) 415 | output.put(", "); 416 | first = false; 417 | 418 | auto itemFormat = getItemFormat(i); 419 | formatValue(itemBytes, itemFormat); 420 | } 421 | 422 | output.put(']'); 423 | } 424 | 425 | /// Convenience wrapper for fixed-size element arrays 426 | private void formatArray(const ubyte[] bytes, OptionFormat elementType, size_t elementSize) 427 | { 428 | enforce(bytes.length % elementSize == 0, "Array bytes length not multiple of element size"); 429 | 430 | formatArray( 431 | // extractItems: split bytes into fixed-size chunks 432 | () { 433 | ubyte[][] items; 434 | for (size_t i = 0; i < bytes.length; i += elementSize) 435 | items ~= bytes[i .. i + elementSize].dup; 436 | return items; 437 | }, 438 | // getItemFormat: all items have the same format 439 | (size_t index) => elementType 440 | ); 441 | } 442 | 443 | /// Format a comment: (text) 444 | /// In JSON mode, comments are not output (no-op) 445 | private void formatComment(string comment) 446 | { 447 | final switch (syntax) 448 | { 449 | case Syntax.json: 450 | // Comments are not part of JSON - no-op 451 | return; 452 | case Syntax.plain: 453 | // Plain mode: no comments (machine-readable) 454 | return; 455 | case Syntax.verbose: 456 | // Verbose mode: output comment as (text) 457 | output.put(' '); 458 | output.put('('); 459 | foreach (ch; comment) 460 | { 461 | if (ch == ')') 462 | output.put('\\'); 463 | output.put(ch); 464 | } 465 | output.put(')'); 466 | break; 467 | } 468 | } 469 | 470 | /// Format an escaped string to output (for use within quotes) 471 | private void formatEscapedString(string s) 472 | { 473 | foreach (char c; s) 474 | { 475 | switch (c) 476 | { 477 | case '\\': output.put(`\\`); break; 478 | case '"': output.put(`\"`); break; 479 | case '\n': output.put(`\n`); break; 480 | case '\r': output.put(`\r`); break; 481 | case '\t': output.put(`\t`); break; 482 | case '\0': 483 | final switch (syntax) 484 | { 485 | case Syntax.json: 486 | // JSON doesn't support \0, use \u0000 487 | output.put(`\u0000`); 488 | break; 489 | case Syntax.plain: 490 | output.put(`\0`); 491 | break; 492 | case Syntax.verbose: 493 | output.put(`\0`); 494 | break; 495 | } 496 | break; 497 | default: 498 | // For printable ASCII, use as-is 499 | if (c >= 0x20 && c <= 0x7E) 500 | output.put(c); 501 | else 502 | { 503 | // For non-printable bytes 504 | final switch (syntax) 505 | { 506 | case Syntax.json: 507 | // JSON requires Unicode escapes 508 | formattedWrite(output, `\u%04X`, cast(ubyte)c); 509 | break; 510 | case Syntax.verbose: 511 | // Minimal mode uses hex escapes 512 | case Syntax.plain: 513 | // Plain mode uses hex escapes like verbose 514 | formattedWrite(output, `\x%02X`, cast(ubyte)c); 515 | break; 516 | } 517 | } 518 | break; 519 | } 520 | } 521 | } 522 | 523 | /// Helper: check if character is whitespace 524 | private static bool isWhitespace(char ch) 525 | { 526 | return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; 527 | } 528 | 529 | /// Helper: check if character is special 530 | private static bool isSpecialChar(char ch) 531 | { 532 | return ch == '=' || ch == '[' || ch == ']' || ch == ',' || ch == '"' || ch == '\\'; 533 | } 534 | 535 | /// Format a struct/map-like value from bytes to output 536 | /// extractFields: extracts field name → value bytes map from the input bytes 537 | /// getFieldFormat: determines the format for each field by name 538 | private void formatStruct( 539 | in ubyte[] bytes, 540 | scope ubyte[][string] delegate(in ubyte[] bytes) extractFields, 541 | scope OptionFormat delegate(string name) getFieldFormat) 542 | { 543 | auto fields = extractFields(bytes); 544 | 545 | // Output opening delimiter 546 | final switch (syntax) 547 | { 548 | case Syntax.json: 549 | output.put('{'); 550 | if (fields.length > 0) 551 | output.put(' '); 552 | break; 553 | case Syntax.plain: 554 | // No opening delimiter in plain mode 555 | break; 556 | case Syntax.verbose: 557 | // No opening delimiter in minimal mode 558 | break; 559 | } 560 | 561 | bool first = true; 562 | foreach (name, valueBytes; fields) 563 | { 564 | if (!first) 565 | output.put(", "); 566 | first = false; 567 | 568 | auto fieldFormat = getFieldFormat(name); 569 | formatField(name, valueBytes, fieldFormat); 570 | } 571 | 572 | // Output closing delimiter 573 | final switch (syntax) 574 | { 575 | case Syntax.json: 576 | if (fields.length > 0) 577 | output.put(' '); 578 | output.put('}'); 579 | break; 580 | case Syntax.plain: 581 | // No closing delimiter in plain mode 582 | break; 583 | case Syntax.verbose: 584 | // No closing delimiter in minimal mode 585 | break; 586 | } 587 | } 588 | 589 | /// Format a TLV list to DSL string format 590 | /// Format: type="value", type2="value2", raw="unparseable" 591 | /// EnumType must have .to!string and be convertible to ubyte 592 | private void formatTLVList(EnumType)(in ubyte[] bytes) 593 | { 594 | formatStruct( 595 | bytes, 596 | // extractFields: parse TLV structure into field map 597 | (in ubyte[] bytes) { 598 | ubyte[][string] fields; 599 | size_t i = 0; 600 | 601 | // Parse TLV suboptions 602 | while (i + 1 < bytes.length) 603 | { 604 | ubyte typeValue = bytes[i]; 605 | ubyte length = bytes[i + 1]; 606 | 607 | // Check if we have enough bytes for this suboption 608 | if (i + 2 + length > bytes.length) 609 | break; // Incomplete suboption - treat as raw 610 | 611 | i += 2; 612 | auto value = bytes[i .. i + length]; 613 | i += length; 614 | 615 | // Get type name 616 | static if (is(EnumType == ubyte)) 617 | { 618 | // For ubyte, just use the number 619 | auto typeName = typeValue.to!string; 620 | } 621 | else 622 | { 623 | // For enums, try to get name 624 | EnumType type = cast(EnumType)typeValue; 625 | string typeName = type.to!string; 626 | 627 | // If type.to!string gives "cast(EnumType)N", use numeric form 628 | if (typeName.startsWith("cast(")) 629 | typeName = typeValue.to!string; 630 | } 631 | 632 | fields[typeName] = value.dup; 633 | } 634 | 635 | // Add raw suffix for any remaining bytes 636 | if (i < bytes.length) 637 | { 638 | fields["raw"] = bytes[i .. $].dup; 639 | } 640 | 641 | return fields; 642 | }, 643 | // getFieldFormat: all TLV values are strings 644 | (string name) => OptionFormat.str 645 | ); 646 | } 647 | 648 | /// Format client identifier from bytes to output 649 | /// Example: [0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF] -> "type=1, clientIdentifier=AA BB CC DD EE FF" 650 | private void formatClientIdentifier(in ubyte[] bytes) 651 | { 652 | enforce(bytes.length >= 1, "Client identifier must have at least 1 byte (type)"); 653 | 654 | formatStruct( 655 | bytes, 656 | // extractFields: split into type and clientIdentifier 657 | (in ubyte[] bytes) { 658 | ubyte[][string] fields; 659 | fields["type"] = bytes[0 .. 1].dup; 660 | if (bytes.length > 1) 661 | fields["clientIdentifier"] = bytes[1 .. $].dup; 662 | else 663 | fields["clientIdentifier"] = []; 664 | return fields; 665 | }, 666 | // getFieldFormat: type is u8, clientIdentifier is hex 667 | (string name) { 668 | if (name == "type") return OptionFormat.u8; 669 | if (name == "clientIdentifier") return OptionFormat.hex; 670 | return OptionFormat.hex; 671 | } 672 | ); 673 | } 674 | 675 | /// Format Client FQDN option from bytes to output (RFC 4702) 676 | /// Format: flags (1 byte) + rcode1 (1 byte) + rcode2 (1 byte) + domain name (DNS wire format) 677 | /// Example: [0x01, 0x00, 0xFF, 0x07, "example", 0x03, "com", 0x00] -> "flags=1, rcode1=0, rcode2=255, name=example.com" 678 | private void formatClientFQDN(in ubyte[] bytes) 679 | { 680 | enforce(bytes.length >= 3, "Client FQDN must have at least 3 bytes (flags, rcode1, rcode2)"); 681 | 682 | ubyte flags = bytes[0]; 683 | ubyte rcode1 = bytes[1]; 684 | ubyte rcode2 = bytes[2]; 685 | auto nameBytes = bytes.length > 3 ? bytes[3 .. $] : []; 686 | 687 | // Format flags field 688 | formatField("flags", [flags], OptionFormat.u8); 689 | output.put(", "); 690 | 691 | // Format rcode1 field 692 | formatField("rcode1", [rcode1], OptionFormat.u8); 693 | output.put(", "); 694 | 695 | // Format rcode2 field 696 | formatField("rcode2", [rcode2], OptionFormat.u8); 697 | output.put(", "); 698 | 699 | // Format name field (DNS wire format) 700 | final switch (syntax) 701 | { 702 | case Syntax.json: 703 | formatScalar("name"); 704 | output.put(": "); 705 | formatScalar(formatDNSName(nameBytes)); 706 | break; 707 | case Syntax.plain: 708 | case Syntax.verbose: 709 | output.put("name="); 710 | formatScalar(formatDNSName(nameBytes)); 711 | break; 712 | } 713 | } 714 | 715 | /// Format User Class array from bytes to output (RFC 3004) 716 | /// Format: array of length-prefixed strings 717 | /// Example: [0x05 "class" 0x04 "test"] -> ["class", "test"] 718 | private void formatUserClassArray(in ubyte[] bytes) 719 | { 720 | auto classes = formatUserClass(bytes); 721 | 722 | // Use formatArray with callback-based approach 723 | formatArray( 724 | // extractItems: convert strings to ubyte[][] (as str format) 725 | () { 726 | ubyte[][] items; 727 | foreach (cls; classes) 728 | items ~= cast(ubyte[])cls; 729 | return items; 730 | }, 731 | // getItemFormat: all items are strings 732 | (size_t index) => OptionFormat.str 733 | ); 734 | } 735 | 736 | /// Format Domain Search list from bytes to output (RFC 3397) 737 | /// Format: array of DNS names (with compression support) 738 | /// Example: [eng.apple.com., marketing.apple.com.] (may use DNS compression) 739 | private void formatDomainSearchArray(in ubyte[] bytes) 740 | { 741 | auto domains = parseDomainSearchList(bytes); 742 | 743 | // Use formatArray with callback-based approach 744 | formatArray( 745 | // extractItems: convert domain strings to ubyte[][] (as str format) 746 | () { 747 | ubyte[][] items; 748 | foreach (domain; domains) 749 | items ~= cast(ubyte[])domain; 750 | return items; 751 | }, 752 | // getItemFormat: all items are strings 753 | (size_t index) => OptionFormat.str 754 | ); 755 | } 756 | } 757 | 758 | // ============================================================================ 759 | // Convenience wrappers 760 | // ============================================================================ 761 | 762 | /// Format a value as DSL string (convenience wrapper) 763 | string formatValue( 764 | const ubyte[] bytes, 765 | OptionFormat type, 766 | Syntax syntax = Syntax.verbose, 767 | void delegate(string) onWarning = null) 768 | { 769 | auto buf = appender!string; 770 | auto formatter = OptionFormatter!(typeof(buf))(buf, syntax, onWarning); 771 | formatter.formatValue(bytes, type); 772 | return buf.data; 773 | } 774 | 775 | // ============================================================================ 776 | // Backwards compatibility wrappers for formats.d API 777 | // ============================================================================ 778 | 779 | /// Format option value to string (formats.d compatibility wrapper) 780 | /// This is an alias to formatValue for backwards compatibility 781 | alias formatOption = formatValue; 782 | 783 | /// Format option value to string without comment (machine-readable plain format) 784 | string formatRawOption(in ubyte[] bytes, OptionFormat fmt, void delegate(string) onWarning = null) 785 | { 786 | return formatValue(bytes, fmt, Syntax.plain, onWarning); 787 | } 788 | -------------------------------------------------------------------------------- /source/dhcptest/formats/parsing.d: -------------------------------------------------------------------------------- 1 | /** 2 | * Parser implementation for DHCP option values. 3 | * 4 | * This module provides a recursive descent parser for converting 5 | * DSL string representations into binary DHCP option data. 6 | */ 7 | module dhcptest.formats.parsing; 8 | 9 | import std.algorithm; 10 | import std.array; 11 | import std.ascii; 12 | import std.conv; 13 | import std.exception : enforce; 14 | import std.format; 15 | import std.range; 16 | import std.string; 17 | 18 | import dhcptest.formats.types; 19 | import dhcptest.options : DHCPOptionType, parseDHCPOptionType, dhcpOptions, DHCPOptionSpec; 20 | 21 | // Import network byte order functions 22 | version (Windows) 23 | static if (__VERSION__ >= 2067) 24 | import core.sys.windows.winsock2 : htons, htonl; 25 | else 26 | import std.c.windows.winsock : htons, htonl; 27 | else 28 | version (Posix) 29 | import core.sys.posix.netdb : htons, htonl; 30 | 31 | /// Lexer/parser for DSL syntax 32 | /// Stateful stream consumer - advances position as it parses 33 | struct OptionParser 34 | { 35 | string input; 36 | size_t pos; 37 | bool atTopLevel; 38 | 39 | this(string input, bool atTopLevel = false) 40 | { 41 | this.input = input; 42 | this.pos = 0; 43 | this.atTopLevel = atTopLevel; 44 | } 45 | 46 | /// Main entry point: parse a value of the given type, return bytes 47 | /// Advances stream position 48 | ubyte[] parseValue(OptionFormat type) 49 | { 50 | // Skip leading whitespace (except for fullString which is greedy) 51 | if (type != OptionFormat.fullString) 52 | skipWhitespace(); 53 | 54 | final switch (type) 55 | { 56 | // Special types (not parseable) 57 | case OptionFormat.unknown: 58 | throw new Exception("Cannot parse unknown format"); 59 | case OptionFormat.special: 60 | throw new Exception("Cannot parse special format"); 61 | 62 | // Scalar types 63 | case OptionFormat.str: 64 | case OptionFormat.fullString: 65 | return cast(ubyte[])readString(type); 66 | 67 | case OptionFormat.hex: 68 | auto s = readString(OptionFormat.str); 69 | return parseHexBytes(s); 70 | 71 | case OptionFormat.ip: 72 | auto s = readString(OptionFormat.str); 73 | return parseIPAddress(s); 74 | 75 | case OptionFormat.boolean: 76 | auto s = readString(OptionFormat.str); 77 | return [s.to!bool ? 1 : 0]; 78 | 79 | case OptionFormat.u8: 80 | auto s = readString(OptionFormat.str); 81 | return [s.to!ubyte]; 82 | 83 | case OptionFormat.u16: 84 | auto s = readString(OptionFormat.str); 85 | return toBytes(s.to!ushort.htons); 86 | 87 | case OptionFormat.u32: 88 | auto s = readString(OptionFormat.str); 89 | return toBytes(s.to!uint.htonl); 90 | 91 | case OptionFormat.duration: 92 | auto s = readString(OptionFormat.str); 93 | return toBytes(parseTimeValue(s).htonl); 94 | 95 | case OptionFormat.dhcpMessageType: 96 | auto s = readString(OptionFormat.str); 97 | return [s.to!DHCPMessageType]; 98 | 99 | case OptionFormat.dhcpOptionType: 100 | auto s = readString(OptionFormat.str); 101 | return [parseDHCPOptionType(s)]; 102 | 103 | case OptionFormat.netbiosNodeType: 104 | auto s = readString(OptionFormat.str); 105 | // Parse NetBIOS node type: "B", "P", "M", "H", or combinations like "BP" 106 | ubyte result = 0; 107 | foreach (c; s) 108 | { 109 | auto idx = NETBIOSNodeTypeChars.indexOf(c); 110 | enforce(idx >= 0, format("Invalid NetBIOS node type character: '%s'", c)); 111 | result |= (1 << idx); 112 | } 113 | return [result]; 114 | 115 | case OptionFormat.processorArchitecture: 116 | auto s = readString(OptionFormat.str); 117 | // Parse processor architecture type (by name or number) 118 | ushort value = parseProcessorArchitecture(s); 119 | // Return as u16 in network byte order (big-endian) 120 | return [cast(ubyte)(value >> 8), cast(ubyte)(value & 0xFF)]; 121 | 122 | case OptionFormat.zeroLength: 123 | auto s = readString(OptionFormat.str); 124 | enforce(s == "present", "Zero-length option value must be \"present\""); 125 | return []; 126 | 127 | // Array types 128 | case OptionFormat.ips: 129 | return parseSpaceSeparatedArray(OptionFormat.ip); 130 | 131 | case OptionFormat.u8s: 132 | return parseSpaceSeparatedArray(OptionFormat.u8); 133 | 134 | case OptionFormat.u16s: 135 | return parseSpaceSeparatedArray(OptionFormat.u16); 136 | 137 | case OptionFormat.u32s: 138 | return parseSpaceSeparatedArray(OptionFormat.u32); 139 | 140 | case OptionFormat.durations: 141 | return parseSpaceSeparatedArray(OptionFormat.duration); 142 | 143 | case OptionFormat.dhcpOptionTypes: 144 | return parseArray(OptionFormat.dhcpOptionType); 145 | 146 | // Struct types - use fullString at top level to consume entire input 147 | case OptionFormat.classlessStaticRoute: 148 | // Support two formats: 149 | // 1. Arrow format: "192.168.2.0/24 -> 192.168.1.50" 150 | // 2. Array format: [["192.168.2.0/24", "192.168.1.50"], ...] 151 | skipWhitespace(); 152 | if (!atEnd && peek() == '[') 153 | { 154 | // Array format - array of [subnet, router] pairs 155 | consume(); // outer '[' 156 | skipWhitespace(); 157 | 158 | ubyte[] result; 159 | bool first = true; 160 | while (!atEnd && peek() != ']') 161 | { 162 | if (!first) 163 | { 164 | expect(','); 165 | skipWhitespace(); 166 | } 167 | first = false; 168 | 169 | // Parse a single route: ["subnet", "router"] 170 | expect('['); 171 | skipWhitespace(); 172 | auto subnet = readString(OptionFormat.str); 173 | skipWhitespace(); 174 | expect(','); 175 | skipWhitespace(); 176 | auto router = readString(OptionFormat.str); 177 | skipWhitespace(); 178 | expect(']'); 179 | 180 | // Append this route to result 181 | result ~= parseClasslessStaticRoute(subnet ~ " -> " ~ router); 182 | skipWhitespace(); 183 | } 184 | expect(']'); // outer ']' 185 | return result; 186 | } 187 | else 188 | { 189 | // Arrow format (legacy) 190 | auto s = readString(atTopLevel ? OptionFormat.fullString : OptionFormat.str); 191 | return parseClasslessStaticRoute(s); 192 | } 193 | 194 | case OptionFormat.clientIdentifier: 195 | return parseClientIdentifier(); 196 | 197 | case OptionFormat.clientFQDN: 198 | return parseClientFQDN(); 199 | 200 | case OptionFormat.userClass: 201 | return parseUserClassArray(); 202 | 203 | case OptionFormat.domainSearch: 204 | return parseDomainSearchArray(); 205 | 206 | case OptionFormat.option: 207 | // Parse DHCP option specification: name[format]=value 208 | // Encoding: [optionType][value...] 209 | auto parsed = parseField((name) { 210 | // Get default format for this option 211 | auto opt = parseDHCPOptionType(name); 212 | return dhcpOptions.get(opt, DHCPOptionSpec.init).format; 213 | }); 214 | string fieldName = parsed[0]; 215 | // OptionFormat fieldFormat = parsed[1]; // Format already applied during parsing 216 | ubyte[] value = parsed[2]; 217 | 218 | // Convert option name to type and encode 219 | auto opt = parseDHCPOptionType(fieldName); 220 | ubyte[] result; 221 | result ~= cast(ubyte)opt; 222 | result ~= value; 223 | return result; 224 | 225 | case OptionFormat.relayAgent: 226 | return parseTLVList!RelayAgentSuboption(); 227 | 228 | case OptionFormat.vendorSpecificInformation: 229 | return parseTLVList!VendorSpecificSuboption(); 230 | } 231 | } 232 | 233 | /// Parse an array: [v1, v2, ...] or v1, v2, ... (top-level only) 234 | /// Recursively calls parseValue for each element 235 | /// At top level, brackets are optional (like fullString) 236 | ubyte[] parseArray(OptionFormat elementType) 237 | { 238 | bool hasBracket = false; 239 | 240 | skipWhitespace(); 241 | 242 | // At top level, brackets are optional 243 | if (atTopLevel) 244 | { 245 | if (!atEnd && peek() == '[') 246 | { 247 | hasBracket = true; 248 | consume(); 249 | } 250 | } 251 | else 252 | { 253 | // Embedded arrays must have brackets 254 | expect('['); 255 | hasBracket = true; 256 | } 257 | 258 | skipWhitespace(); 259 | 260 | ubyte[] result; 261 | 262 | // Empty array - only possible with brackets 263 | if (hasBracket && tryConsume(']')) 264 | return result; 265 | 266 | // When parsing array elements, we're no longer at top level 267 | // (to avoid greedy consumption of entire input by readPhrase) 268 | auto savedTopLevel = atTopLevel; 269 | atTopLevel = false; 270 | scope(exit) atTopLevel = savedTopLevel; 271 | 272 | while (true) 273 | { 274 | // Parse element (now with atTopLevel=false) 275 | result ~= parseValue(elementType); 276 | 277 | // Optional comment after element 278 | if (peekComment()) 279 | skipComment(); 280 | 281 | skipWhitespace(); 282 | 283 | // Check for end based on whether we have bracket 284 | if (hasBracket) 285 | { 286 | // With bracket: check for ']' or ',' 287 | if (tryConsume(']')) 288 | break; 289 | expect(','); 290 | skipWhitespace(); 291 | // Check for trailing comma 292 | if (tryConsume(']')) 293 | break; 294 | } 295 | else 296 | { 297 | // Without bracket (top level only): check for end of input or ',' 298 | if (atEnd) 299 | break; 300 | expect(','); 301 | skipWhitespace(); 302 | // After comma, if we hit end, that's OK (trailing comma) 303 | if (atEnd) 304 | break; 305 | } 306 | } 307 | 308 | return result; 309 | } 310 | 311 | /// Parse a space-separated array where whitespace acts as a delimiter 312 | /// Supports: "1 2 3", "1,2,3", "1 2, 3 4", "[1, 2 3]" 313 | /// Used for types where whitespace is never valid within values (IPs, integers) 314 | ubyte[] parseSpaceSeparatedArray(OptionFormat elementType) 315 | { 316 | bool hasBracket = false; 317 | 318 | skipWhitespace(); 319 | 320 | // At top level, brackets are optional 321 | if (atTopLevel) 322 | { 323 | if (!atEnd && peek() == '[') 324 | { 325 | hasBracket = true; 326 | consume(); 327 | } 328 | } 329 | else 330 | { 331 | // Embedded arrays must have brackets 332 | expect('['); 333 | hasBracket = true; 334 | } 335 | 336 | skipWhitespace(); 337 | 338 | ubyte[] result; 339 | 340 | // Empty array - only possible with brackets 341 | if (hasBracket && tryConsume(']')) 342 | return result; 343 | 344 | // Parse elements with space-separated semantics 345 | auto savedTopLevel = atTopLevel; 346 | atTopLevel = false; 347 | scope(exit) atTopLevel = savedTopLevel; 348 | 349 | while (true) 350 | { 351 | // Parse element using word parser (stops at whitespace) 352 | auto str = readWord(); 353 | if (str.length == 0) 354 | break; 355 | 356 | // Parse the string value according to element type 357 | auto p = OptionParser(str, false); 358 | result ~= p.parseValue(elementType); 359 | 360 | // Optional comment after element 361 | if (peekComment()) 362 | skipComment(); 363 | 364 | // Skip whitespace and optional commas 365 | skipWhitespace(); 366 | if (!atEnd && peek() == ',') 367 | { 368 | consume(); 369 | skipWhitespace(); 370 | } 371 | 372 | // Check for end 373 | if (hasBracket) 374 | { 375 | if (!atEnd && peek() == ']') 376 | { 377 | consume(); 378 | break; 379 | } 380 | } 381 | else 382 | { 383 | if (atEnd) 384 | break; 385 | } 386 | } 387 | 388 | return result; 389 | } 390 | 391 | /// Parse a TLV (Type-Length-Value) list 392 | /// Format: type="value", type2="value2", raw="unparseable" 393 | /// EnumType is the enum for suboption types (or ubyte for no enum) 394 | ubyte[] parseTLVList(EnumType)() 395 | { 396 | return parseStruct( 397 | // getDefaultFieldFormat: all TLV values are strings 398 | (string name) => OptionFormat.str, 399 | // finalize: encode fields as TLV bytes 400 | (ubyte[][string] fields) { 401 | ubyte[] result; 402 | 403 | foreach (name, valueBytes; fields) 404 | { 405 | auto valueStr = cast(string)valueBytes; 406 | 407 | // Check if this is the "raw" pseudo-suboption 408 | if (name == "raw") 409 | { 410 | // Raw bytes - no type/length prefix 411 | result ~= cast(ubyte[])valueStr; 412 | } 413 | else 414 | { 415 | // Regular suboption - encode as TLV 416 | ubyte typeValue; 417 | 418 | // Try to parse typeName as numeric first, then as enum name 419 | static if (is(EnumType == ubyte)) 420 | { 421 | // For ubyte alias, just parse as number 422 | typeValue = name.to!ubyte; 423 | } 424 | else 425 | { 426 | // For enum types, try enum name first, then numeric 427 | try 428 | { 429 | typeValue = cast(ubyte)name.to!EnumType; 430 | } 431 | catch (Exception) 432 | { 433 | // Try as numeric 434 | typeValue = name.to!ubyte; 435 | } 436 | } 437 | 438 | auto value = cast(ubyte[])valueStr; 439 | enforce(value.length <= 255, "Suboption value too long (max 255 bytes)"); 440 | 441 | result ~= typeValue; 442 | result ~= cast(ubyte)value.length; 443 | result ~= value; 444 | } 445 | } 446 | 447 | return result; 448 | } 449 | ); 450 | } 451 | 452 | /// Parse client identifier struct 453 | /// Format: type=N, clientIdentifier=hex 454 | ubyte[] parseClientIdentifier() 455 | { 456 | return parseStruct( 457 | // getDefaultFieldFormat: type is u8, clientIdentifier is hex 458 | (string name) { 459 | if (name == "type") return OptionFormat.u8; 460 | if (name == "clientIdentifier") return OptionFormat.hex; 461 | return OptionFormat.hex; 462 | }, 463 | // finalize: encode as type byte followed by identifier bytes 464 | (ubyte[][string] fields) { 465 | enforce("type" in fields, "Client identifier must have 'type' field"); 466 | enforce("clientIdentifier" in fields, "Client identifier must have 'clientIdentifier' field"); 467 | 468 | ubyte[] result = fields["type"]; 469 | result ~= fields["clientIdentifier"]; 470 | return result; 471 | } 472 | ); 473 | } 474 | 475 | /// Parse Client FQDN struct (RFC 4702) 476 | /// Format: flags=N, rcode1=N, rcode2=N, name=domain.com 477 | ubyte[] parseClientFQDN() 478 | { 479 | return parseStruct( 480 | // getDefaultFieldFormat: flags/rcode1/rcode2 are u8, name is str (will be converted to DNS wire format) 481 | (string name) { 482 | if (name == "flags") return OptionFormat.u8; 483 | if (name == "rcode1") return OptionFormat.u8; 484 | if (name == "rcode2") return OptionFormat.u8; 485 | if (name == "name") return OptionFormat.str; 486 | return OptionFormat.u8; 487 | }, 488 | // finalize: encode as flags + rcode1 + rcode2 + DNS wire format name 489 | (ubyte[][string] fields) { 490 | enforce("flags" in fields, "Client FQDN must have 'flags' field"); 491 | enforce("rcode1" in fields, "Client FQDN must have 'rcode1' field"); 492 | enforce("rcode2" in fields, "Client FQDN must have 'rcode2' field"); 493 | enforce("name" in fields, "Client FQDN must have 'name' field"); 494 | 495 | ubyte[] result; 496 | result ~= fields["flags"]; 497 | result ~= fields["rcode1"]; 498 | result ~= fields["rcode2"]; 499 | 500 | // Convert the name string to DNS wire format 501 | auto nameStr = cast(string)fields["name"]; 502 | result ~= parseDNSName(nameStr); 503 | 504 | return result; 505 | } 506 | ); 507 | } 508 | 509 | /// Parse User Class array (RFC 3004) 510 | /// Format: ["class1", "class2", ...] or just "single" for a single class 511 | ubyte[] parseUserClassArray() 512 | { 513 | string[] classes; 514 | 515 | skipWhitespace(); 516 | 517 | // Check if we have an array with brackets 518 | if (!atEnd && peek() == '[') 519 | { 520 | // Array format: [class1, class2] or ["class1", "class2"] 521 | consume(); // consume '[' 522 | skipWhitespace(); 523 | 524 | // Save atTopLevel state and set to false for parsing array elements 525 | bool savedAtTopLevel = atTopLevel; 526 | atTopLevel = false; 527 | 528 | bool first = true; 529 | while (!atEnd && peek() != ']') 530 | { 531 | if (!first) 532 | { 533 | skipWhitespace(); 534 | expect(','); 535 | skipWhitespace(); 536 | } 537 | first = false; 538 | 539 | // Read each string (handles both quoted and unquoted) 540 | auto cls = readString(OptionFormat.str); 541 | classes ~= cls; 542 | skipWhitespace(); 543 | } 544 | 545 | // Restore atTopLevel state 546 | atTopLevel = savedAtTopLevel; 547 | 548 | expect(']'); 549 | } 550 | else 551 | { 552 | // Single string format: class or "class" 553 | auto cls = readString(atTopLevel ? OptionFormat.fullString : OptionFormat.str); 554 | classes ~= cls; 555 | } 556 | 557 | return parseUserClass(classes); 558 | } 559 | 560 | /// Parse Domain Search array (RFC 3397) 561 | /// Format: ["example.com", "test.org"] or just "example.com" for a single domain 562 | ubyte[] parseDomainSearchArray() 563 | { 564 | string[] domains; 565 | 566 | skipWhitespace(); 567 | 568 | // Check if we have an array with brackets 569 | if (!atEnd && peek() == '[') 570 | { 571 | // Array format: [domain1, domain2] or ["domain1", "domain2"] 572 | consume(); // consume '[' 573 | skipWhitespace(); 574 | 575 | // Save atTopLevel state and set to false for parsing array elements 576 | bool savedAtTopLevel = atTopLevel; 577 | atTopLevel = false; 578 | 579 | bool first = true; 580 | while (!atEnd && peek() != ']') 581 | { 582 | if (!first) 583 | { 584 | skipWhitespace(); 585 | expect(','); 586 | skipWhitespace(); 587 | } 588 | first = false; 589 | 590 | // Read each domain name (handles both quoted and unquoted) 591 | auto domain = readString(OptionFormat.str); 592 | domains ~= domain; 593 | skipWhitespace(); 594 | } 595 | 596 | // Restore atTopLevel state 597 | atTopLevel = savedAtTopLevel; 598 | 599 | expect(']'); 600 | } 601 | else 602 | { 603 | // Single domain format: domain or "domain" 604 | auto domain = readString(atTopLevel ? OptionFormat.fullString : OptionFormat.str); 605 | domains ~= domain; 606 | } 607 | 608 | return formatDomainSearchList(domains); 609 | } 610 | 611 | /// Parse field name with optional format override: name or name[format] 612 | /// JSON mode: "name" or "name[format]" (quoted, no separate format override) 613 | /// Returns: tuple of (field name, format if specified, null otherwise) 614 | auto parseFieldSpec() 615 | { 616 | import std.typecons : tuple, Nullable; 617 | 618 | string name; 619 | bool isQuoted = false; 620 | 621 | // Parse field name - either quoted or unquoted 622 | if (!atEnd && peek() == '"') 623 | { 624 | // Quoted field name (JSON style) - includes everything in quotes 625 | name = readQuoted(); 626 | isQuoted = true; 627 | } 628 | else 629 | { 630 | // Unquoted field name (DSL style) - stops at special chars 631 | auto nameStart = pos; 632 | while (!atEnd && !isSpecialChar(peek()) && !isWhitespace(peek())) 633 | consume(); 634 | name = input[nameStart .. pos]; 635 | enforce(name.length > 0, "Expected field name"); 636 | } 637 | 638 | skipWhitespace(); 639 | 640 | // Optional format override: field[format] 641 | // Only available for unquoted field names 642 | Nullable!OptionFormat formatOverride; 643 | if (!isQuoted && tryConsume('[')) 644 | { 645 | auto fmtStart = pos; 646 | while (!atEnd && peek() != ']') 647 | consume(); 648 | auto formatName = input[fmtStart .. pos]; 649 | expect(']'); 650 | skipWhitespace(); 651 | 652 | // Parse format name to OptionFormat enum 653 | formatOverride = formatName.to!OptionFormat; 654 | } 655 | 656 | return tuple(name, formatOverride); 657 | } 658 | 659 | /// Parse a single field with format: name[format]=value 660 | /// Returns: tuple of (field name, format used, value bytes) 661 | auto parseField(scope OptionFormat delegate(string name) getDefaultFieldFormat) 662 | { 663 | import std.typecons : tuple; 664 | 665 | auto spec = parseFieldSpec(); 666 | string name = spec[0]; 667 | auto formatOverride = spec[1]; 668 | 669 | // Use format override if specified, otherwise use default 670 | OptionFormat fieldFormat = formatOverride.isNull 671 | ? getDefaultFieldFormat(name) 672 | : formatOverride.get; 673 | 674 | // Accept both = (DSL) and : (JSON) as field separators 675 | if (!tryConsume('=') && !tryConsume(':')) 676 | throw new Exception(format("Expected '=' or ':' at position %d", pos)); 677 | skipWhitespace(); 678 | 679 | // Parse value according to field format 680 | ubyte[] value = parseValue(fieldFormat); 681 | 682 | return tuple(name, fieldFormat, value); 683 | } 684 | 685 | /// Parse a struct/map-like value with field=value pairs 686 | /// Supports: [field=value], {field:value}, or top-level field=value 687 | /// getDefaultFieldFormat: determines the format for each field by name 688 | /// finalize: converts the parsed field map to final bytes 689 | ubyte[] parseStruct( 690 | scope OptionFormat delegate(string name) getDefaultFieldFormat, 691 | scope ubyte[] delegate(ubyte[][string] fields) finalize) 692 | { 693 | char delimiter = '\0'; // '\0' = none, '[' = bracket, '{' = brace 694 | skipWhitespace(); 695 | 696 | // Delimiters optional at top level 697 | if (atTopLevel) 698 | { 699 | if (!atEnd && (peek() == '[' || peek() == '{')) 700 | { 701 | delimiter = consume(); 702 | } 703 | } 704 | else 705 | { 706 | // Embedded structs require delimiters 707 | if (!atEnd && (peek() == '[' || peek() == '{')) 708 | delimiter = consume(); 709 | else 710 | expect('['); // Default to expecting bracket for error message 711 | } 712 | 713 | skipWhitespace(); 714 | 715 | // Empty struct 716 | if (delimiter != '\0') 717 | { 718 | char closingDelimiter = (delimiter == '[') ? ']' : '}'; 719 | if (tryConsume(closingDelimiter)) 720 | return finalize(null); 721 | } 722 | 723 | ubyte[][string] fields; 724 | 725 | auto savedTopLevel = atTopLevel; 726 | atTopLevel = false; 727 | scope(exit) atTopLevel = savedTopLevel; 728 | 729 | while (true) 730 | { 731 | skipWhitespace(); 732 | 733 | // Check for end before trying to parse field name 734 | if (delimiter != '\0') 735 | { 736 | char closingDelimiter = (delimiter == '[') ? ']' : '}'; 737 | if (!atEnd && peek() == closingDelimiter) 738 | break; 739 | } 740 | else if (atEnd) 741 | break; 742 | 743 | // Parse single field using parseField helper 744 | auto parsed = parseField(getDefaultFieldFormat); 745 | string name = parsed[0]; 746 | // OptionFormat fieldFormat = parsed[1]; // Not needed here 747 | ubyte[] value = parsed[2]; 748 | 749 | // Store in fields map 750 | fields[name] = value; 751 | 752 | // Optional comment 753 | if (peekComment()) 754 | skipComment(); 755 | 756 | skipWhitespace(); 757 | 758 | // Check for end or comma 759 | if (delimiter != '\0') 760 | { 761 | char closingDelimiter = (delimiter == '[') ? ']' : '}'; 762 | if (tryConsume(closingDelimiter)) 763 | break; 764 | } 765 | else 766 | { 767 | // At top level without delimiters, stop at end of input 768 | if (atEnd) 769 | break; 770 | } 771 | 772 | // Expect comma between fields 773 | if (!tryConsume(',')) 774 | { 775 | if (delimiter != '\0') 776 | expect(','); // With delimiters, comma is required 777 | else 778 | break; // At top level, comma is optional 779 | } 780 | 781 | skipWhitespace(); 782 | 783 | // Check for trailing comma 784 | if (delimiter != '\0') 785 | { 786 | char closingDelimiter = (delimiter == '[') ? ']' : '}'; 787 | if (tryConsume(closingDelimiter)) 788 | break; 789 | } 790 | } 791 | 792 | return finalize(fields); 793 | } 794 | 795 | // ======================================================================== 796 | // Low-level parsing primitives 797 | // ======================================================================== 798 | 799 | /// Check if we're at end of input 800 | bool atEnd() const 801 | { 802 | return pos >= input.length; 803 | } 804 | 805 | /// Peek at current character without consuming 806 | char peek() const 807 | { 808 | enforce(!atEnd, "Unexpected end of input"); 809 | return input[pos]; 810 | } 811 | 812 | /// Peek ahead n characters 813 | char peekAhead(size_t n) const 814 | { 815 | enforce(pos + n < input.length, "Unexpected end of input"); 816 | return input[pos + n]; 817 | } 818 | 819 | /// Check if character is available at offset 820 | bool hasChar(size_t offset = 0) const 821 | { 822 | return pos + offset < input.length; 823 | } 824 | 825 | /// Consume and return current character 826 | char consume() 827 | { 828 | enforce(!atEnd, "Unexpected end of input"); 829 | return input[pos++]; 830 | } 831 | 832 | /// Consume specific character or throw 833 | void expect(char ch) 834 | { 835 | auto got = consume(); 836 | enforce(got == ch, format("Expected '%s' but got '%s' at position %d", ch, got, pos - 1)); 837 | } 838 | 839 | /// Try to consume specific character, return success 840 | bool tryConsume(char ch) 841 | { 842 | if (!atEnd && peek() == ch) 843 | { 844 | consume(); 845 | return true; 846 | } 847 | return false; 848 | } 849 | 850 | /// Skip whitespace (spaces, tabs, newlines, carriage returns) 851 | void skipWhitespace() 852 | { 853 | while (!atEnd && isWhitespace(peek())) 854 | consume(); 855 | } 856 | 857 | /// Check if a comment follows (whitespace + '(') 858 | bool peekComment() 859 | { 860 | size_t savedPos = pos; 861 | scope(exit) pos = savedPos; 862 | 863 | // Must have at least one whitespace before '(' 864 | if (atEnd || !isWhitespace(peek())) 865 | return false; 866 | 867 | // Skip whitespace 868 | while (!atEnd && isWhitespace(peek())) 869 | consume(); 870 | 871 | // Check for '(' 872 | return !atEnd && peek() == '('; 873 | } 874 | 875 | /// Parse and discard a comment: whitespace followed by (...) 876 | void skipComment() 877 | { 878 | // Skip required whitespace 879 | enforce(!atEnd && isWhitespace(peek()), "Comment must be preceded by whitespace"); 880 | skipWhitespace(); 881 | 882 | expect('('); 883 | 884 | // Skip comment content 885 | while (!atEnd && peek() != ')') 886 | { 887 | if (peek() == '\\' && hasChar(1) && peekAhead(1) == ')') 888 | { 889 | consume(); // consume backslash 890 | consume(); // consume ')' 891 | } 892 | else 893 | { 894 | consume(); 895 | } 896 | } 897 | 898 | expect(')'); 899 | } 900 | 901 | /// Check if character is whitespace 902 | private static bool isWhitespace(char ch) 903 | { 904 | return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; 905 | } 906 | 907 | /// Check if character is a special character (needs escaping in unquoted strings) 908 | private static bool isSpecialChar(char ch) 909 | { 910 | return ch == '=' || ch == ':' || ch == '[' || ch == ']' || ch == '{' || ch == '}' || ch == ',' || ch == '"' || ch == '\\'; 911 | } 912 | 913 | /// Parse an escape sequence, return the unescaped character 914 | private char parseEscape() 915 | { 916 | expect('\\'); 917 | enforce(!atEnd, "Incomplete escape sequence at end of input"); 918 | auto ch = consume(); 919 | 920 | // Handle escape sequences 921 | switch (ch) 922 | { 923 | case '\\': 924 | case '"': 925 | case '[': 926 | case ']': 927 | case '=': 928 | case ',': 929 | case '(': 930 | case ')': 931 | return ch; 932 | case '0': 933 | return '\0'; 934 | case 'n': 935 | return '\n'; 936 | case 'r': 937 | return '\r'; 938 | case 't': 939 | return '\t'; 940 | case 'x': 941 | // Hex escape: \xHH 942 | enforce(pos + 1 < input.length, "Incomplete hex escape sequence"); 943 | auto hexDigits = input[pos .. pos + 2]; 944 | pos += 2; 945 | return cast(char)hexDigits.to!ubyte(16); 946 | case 'u': 947 | // Unicode escape: \uXXXX (JSON-style) 948 | enforce(pos + 3 < input.length, "Incomplete unicode escape sequence"); 949 | auto unicodeDigits = input[pos .. pos + 4]; 950 | pos += 4; 951 | auto codepoint = unicodeDigits.to!ushort(16); 952 | // For ASCII range, return as char 953 | enforce(codepoint <= 0xFF, "Unicode escapes beyond \\u00FF not supported"); 954 | return cast(char)codepoint; 955 | default: 956 | throw new Exception(format("Invalid escape sequence '\\%s' at position %d", ch, pos - 1)); 957 | } 958 | } 959 | 960 | /// Parse a quoted string: "..." with escape sequences 961 | string readQuoted() 962 | { 963 | expect('"'); 964 | auto result = appender!string; 965 | 966 | while (!atEnd && peek() != '"') 967 | { 968 | if (peek() == '\\') 969 | result.put(parseEscape()); 970 | else 971 | result.put(consume()); 972 | } 973 | 974 | expect('"'); 975 | return result.data; 976 | } 977 | 978 | /// Parse a phrase (multi-word unquoted value) 979 | /// Stops at: end of input, delimiters (,]), or comments 980 | /// Consumes: most characters including whitespace, dots, colons, hyphens 981 | /// Used for values like "DE AD BE EF" or "Router Option" 982 | string readPhrase() 983 | { 984 | auto result = appender!string; 985 | 986 | while (!atEnd) 987 | { 988 | // Check for comment (whitespace followed by '(') 989 | if (peekComment()) 990 | break; 991 | 992 | // Check for escape sequence first 993 | if (peek() == '\\') 994 | { 995 | result.put(parseEscape()); 996 | continue; 997 | } 998 | 999 | // At top level, consume everything (already handled escapes and comments) 1000 | if (atTopLevel) 1001 | { 1002 | result.put(consume()); 1003 | } 1004 | // In embedded context, only stop at actual delimiters: , and ] 1005 | else 1006 | { 1007 | char ch = peek(); 1008 | if (ch == ',' || ch == ']') 1009 | break; 1010 | result.put(consume()); 1011 | } 1012 | } 1013 | 1014 | import std.string : strip; 1015 | return result.data.strip(); 1016 | } 1017 | 1018 | /// Parse a word (single-word unquoted value, stops at whitespace) 1019 | /// Used for space-separated arrays where whitespace is a delimiter 1020 | /// Stops at: end of input, delimiters (,]), whitespace, or comments 1021 | /// Examples: "192.168.1.1", "42", "3600" 1022 | string readWord() 1023 | { 1024 | auto result = appender!string; 1025 | 1026 | while (!atEnd) 1027 | { 1028 | // Check for comment 1029 | if (peekComment()) 1030 | break; 1031 | 1032 | // Check for escape sequence first 1033 | if (peek() == '\\') 1034 | { 1035 | result.put(parseEscape()); 1036 | continue; 1037 | } 1038 | 1039 | char ch = peek(); 1040 | // Stop at whitespace, commas, or closing bracket 1041 | if (isWhitespace(ch) || ch == ',' || ch == ']') 1042 | break; 1043 | 1044 | result.put(consume()); 1045 | } 1046 | 1047 | import std.string : strip; 1048 | return result.data.strip(); 1049 | } 1050 | 1051 | /// Parse a full string (greedy, only at top level) 1052 | /// Consumes everything until end of input (or whitespace + comment) 1053 | string readFullString() 1054 | { 1055 | enforce(atTopLevel, "full_string format only available at top level"); 1056 | 1057 | auto result = appender!string; 1058 | 1059 | while (!atEnd) 1060 | { 1061 | // Check for comment 1062 | if (peekComment()) 1063 | break; 1064 | 1065 | result.put(consume()); 1066 | } 1067 | 1068 | return result.data; 1069 | } 1070 | 1071 | /// Parse a string based on type 1072 | string readString(OptionFormat type) 1073 | { 1074 | final switch (type) 1075 | { 1076 | case OptionFormat.fullString: 1077 | return readFullString(); 1078 | case OptionFormat.str: 1079 | // Skip leading whitespace for str format 1080 | skipWhitespace(); 1081 | // Check if quoted 1082 | if (!atEnd && peek() == '"') 1083 | return readQuoted(); 1084 | else 1085 | return readPhrase(); 1086 | case OptionFormat.unknown: 1087 | case OptionFormat.special: 1088 | case OptionFormat.hex: 1089 | case OptionFormat.ip: 1090 | case OptionFormat.boolean: 1091 | case OptionFormat.u8: 1092 | case OptionFormat.u16: 1093 | case OptionFormat.u32: 1094 | case OptionFormat.duration: 1095 | case OptionFormat.dhcpMessageType: 1096 | case OptionFormat.dhcpOptionType: 1097 | case OptionFormat.netbiosNodeType: 1098 | case OptionFormat.processorArchitecture: 1099 | case OptionFormat.zeroLength: 1100 | case OptionFormat.ips: 1101 | case OptionFormat.u8s: 1102 | case OptionFormat.u16s: 1103 | case OptionFormat.u32s: 1104 | case OptionFormat.durations: 1105 | case OptionFormat.dhcpOptionTypes: 1106 | case OptionFormat.relayAgent: 1107 | case OptionFormat.vendorSpecificInformation: 1108 | case OptionFormat.classlessStaticRoute: 1109 | case OptionFormat.clientIdentifier: 1110 | case OptionFormat.clientFQDN: 1111 | case OptionFormat.userClass: 1112 | case OptionFormat.domainSearch: 1113 | case OptionFormat.option: 1114 | throw new Exception("readString called with non-string type"); 1115 | } 1116 | } 1117 | 1118 | /// Parse hex bytes from string (e.g., "DEADBEEF" -> [0xDE, 0xAD, 0xBE, 0xEF]) 1119 | private static ubyte[] parseHexBytes(string s) 1120 | { 1121 | s = s.replace(" ", "").replace(":", "").replace("-", ""); 1122 | enforce(s.length % 2 == 0, "Hex string must have even length"); 1123 | 1124 | ubyte[] result; 1125 | for (size_t i = 0; i < s.length; i += 2) 1126 | { 1127 | result ~= s[i .. i + 2].to!ubyte(16); 1128 | } 1129 | return result; 1130 | } 1131 | 1132 | /// Parse IP address from string (e.g., "192.168.1.1" -> [192, 168, 1, 1]) 1133 | private static ubyte[] parseIPAddress(string s) 1134 | { 1135 | // Allow spaces and commas as separators (for backwards compatibility) 1136 | s = s.replace(" ", ".").replace(",", "."); 1137 | auto parts = s.split("."); 1138 | enforce(parts.length == 4, "IP address must have 4 octets"); 1139 | 1140 | ubyte[] result; 1141 | foreach (part; parts) 1142 | result ~= part.to!ubyte; 1143 | 1144 | return result; 1145 | } 1146 | 1147 | /// Convert numeric type to bytes (preserving byte order) 1148 | private static ubyte[] toBytes(T)(T value) 1149 | { 1150 | return (cast(ubyte*)&value)[0 .. T.sizeof].dup; 1151 | } 1152 | } 1153 | 1154 | // ============================================================================ 1155 | // Backwards compatibility wrappers for formats.d API 1156 | // ============================================================================ 1157 | 1158 | /// Parse option value from string (formats.d compatibility wrapper) 1159 | /// This is the old API - new code should use OptionParser directly 1160 | ubyte[] parseOption(string value, OptionFormat fmt) 1161 | { 1162 | auto parser = OptionParser(value, true); // atTopLevel=true 1163 | auto result = parser.parseValue(fmt); 1164 | 1165 | // Skip optional trailing comment 1166 | if (parser.peekComment()) 1167 | parser.skipComment(); 1168 | 1169 | enforce(parser.pos == parser.input.length, 1170 | format("Unexpected trailing input at position %d: '%s'", parser.pos, parser.input[parser.pos..$])); 1171 | return result; 1172 | } 1173 | -------------------------------------------------------------------------------- /source/dhcptest/formats/tests.d: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for the formats package. 3 | * 4 | * This module contains all unit tests for parsing and formatting 5 | * DHCP option values. 6 | */ 7 | module dhcptest.formats.tests; 8 | 9 | import std.algorithm; 10 | import std.conv; 11 | import std.exception; 12 | import std.format; 13 | 14 | import dhcptest.formats.types; 15 | import dhcptest.formats.parsing; 16 | import dhcptest.formats.formatting; 17 | 18 | // ============================================================================ 19 | // Test Helpers 20 | // ============================================================================ 21 | 22 | /// Test roundtrip: parse -> format -> parse for all syntax modes 23 | /// Ensures that parsing and formatting are inverse operations 24 | /// For JSON syntax, validates that output is valid JSON (except for known exceptions) 25 | private void testRoundtrip(string input, OptionFormat fmt) 26 | { 27 | import std.traits : EnumMembers; 28 | import std.json; 29 | 30 | // Formats that don't produce full-self-contained valid JSON in JSON mode 31 | // NOTE: These formats need to be made syntax-aware to properly support JSON mode 32 | static immutable OptionFormat[] jsonExceptions = [ 33 | OptionFormat.option, // Field assignment without object braces 34 | ]; 35 | 36 | // Parse original input 37 | auto bytes1 = parseOption(input, fmt); 38 | 39 | // Test each syntax mode 40 | foreach (syntax; [EnumMembers!Syntax]) 41 | { 42 | // Format with this syntax 43 | auto formatted = formatValue(bytes1, fmt, syntax); 44 | 45 | // Parse formatted output 46 | auto bytes2 = parseOption(formatted, fmt); 47 | 48 | // Should produce same bytes 49 | assert(bytes1 == bytes2, format("Roundtrip failed for %s with %s syntax: %s -> %s -> %s", 50 | fmt, syntax, input, formatted, bytes2)); 51 | 52 | // For JSON syntax, validate it's valid JSON (except for known exceptions) 53 | if (syntax == Syntax.json && !jsonExceptions.canFind(fmt)) 54 | { 55 | try 56 | { 57 | // Use strict parsing to detect trailing data 58 | auto parsed = parseJSON(formatted, JSONOptions.strictParsing); 59 | // Note: We don't check for object type - scalars and arrays are also valid JSON 60 | } 61 | catch (Exception e) 62 | { 63 | assert(false, format("Invalid JSON for %s: %s\nOutput: %s\nError: %s", 64 | fmt, input, formatted, e.msg)); 65 | } 66 | } 67 | } 68 | } 69 | 70 | // ============================================================================ 71 | // Unit Tests 72 | // ============================================================================ 73 | 74 | unittest 75 | { 76 | // Test u8 parsing 77 | { 78 | auto p = OptionParser("42"); 79 | assert(p.parseValue(OptionFormat.u8) == [42]); 80 | } 81 | 82 | // Test u8 array parsing (with brackets) 83 | { 84 | auto p = OptionParser("[1, 2, 3]"); 85 | assert(p.parseValue(OptionFormat.u8s) == [1, 2, 3]); 86 | } 87 | 88 | // Test u8 array parsing (without brackets, top-level) 89 | { 90 | auto p = OptionParser("1, 2, 3", true); // atTopLevel = true 91 | assert(p.parseValue(OptionFormat.u8s) == [1, 2, 3]); 92 | } 93 | 94 | // Test u8 array parsing (no spaces) 95 | { 96 | auto p = OptionParser("1,2,3", true); // atTopLevel = true 97 | assert(p.parseValue(OptionFormat.u8s) == [1, 2, 3]); 98 | } 99 | 100 | // Test IP parsing 101 | { 102 | auto p = OptionParser("192.168.1.1"); 103 | assert(p.parseValue(OptionFormat.ip) == [192, 168, 1, 1]); 104 | } 105 | 106 | // Test IP array parsing (with brackets) 107 | { 108 | auto p = OptionParser("[192.168.1.1, 10.0.0.1]"); 109 | auto result = p.parseValue(OptionFormat.ips); 110 | assert(result == [192, 168, 1, 1, 10, 0, 0, 1]); 111 | } 112 | 113 | // Test IP array parsing (without brackets, top-level) 114 | { 115 | auto p = OptionParser("192.168.1.1, 10.0.0.1", true); // atTopLevel = true 116 | auto result = p.parseValue(OptionFormat.ips); 117 | assert(result == [192, 168, 1, 1, 10, 0, 0, 1]); 118 | } 119 | 120 | // Test hex parsing 121 | { 122 | auto p = OptionParser("DEADBEEF"); 123 | assert(p.parseValue(OptionFormat.hex) == [0xDE, 0xAD, 0xBE, 0xEF]); 124 | } 125 | 126 | // Test boolean parsing 127 | { 128 | auto p = OptionParser("true"); 129 | assert(p.parseValue(OptionFormat.boolean) == [1]); 130 | } 131 | 132 | // Test zero-length parsing 133 | { 134 | auto p = OptionParser("present"); 135 | assert(p.parseValue(OptionFormat.zeroLength) == []); 136 | } 137 | } 138 | 139 | unittest 140 | { 141 | // Test formatting 142 | { 143 | assert(formatValue([42], OptionFormat.u8) == "42"); 144 | assert(formatValue([1, 2, 3], OptionFormat.u8s) == "[1, 2, 3]"); 145 | assert(formatValue([192, 168, 1, 1], OptionFormat.ip) == "192.168.1.1"); 146 | assert(formatValue([192, 168, 1, 1, 10, 0, 0, 1], OptionFormat.ips) == "[192.168.1.1, 10.0.0.1]"); 147 | // Hex now uses maybeAscii format with spaces 148 | assert(formatValue([0xDE, 0xAD, 0xBE, 0xEF], OptionFormat.hex) == "DE AD BE EF"); 149 | // Hex with ASCII shows ASCII in comment 150 | assert(formatValue(cast(ubyte[])"test", OptionFormat.hex) == "74 65 73 74 (test)"); 151 | assert(formatValue([1], OptionFormat.boolean) == "true"); 152 | assert(formatValue([0], OptionFormat.boolean) == "false"); 153 | assert(formatValue([], OptionFormat.zeroLength) == "present"); 154 | } 155 | } 156 | 157 | unittest 158 | { 159 | // Test round-trip for multiple types (now tests all syntax modes) 160 | testRoundtrip("42", OptionFormat.u8); 161 | testRoundtrip("[1, 2, 3]", OptionFormat.u8s); 162 | testRoundtrip("192.168.1.1", OptionFormat.ip); 163 | testRoundtrip("DEADBEEF", OptionFormat.hex); 164 | testRoundtrip("true", OptionFormat.boolean); 165 | testRoundtrip("present", OptionFormat.zeroLength); 166 | } 167 | 168 | unittest 169 | { 170 | // Test time unit parsing 171 | { 172 | auto p1 = OptionParser("3600"); 173 | assert(p1.parseValue(OptionFormat.duration) == [0, 0, 14, 16]); // 3600 in network byte order 174 | 175 | auto p2 = OptionParser("1h"); 176 | assert(p2.parseValue(OptionFormat.duration) == [0, 0, 14, 16]); // 1 hour = 3600 seconds 177 | 178 | auto p3 = OptionParser("60m"); 179 | assert(p3.parseValue(OptionFormat.duration) == [0, 0, 14, 16]); // 60 minutes = 3600 seconds 180 | 181 | auto p4 = OptionParser("3600s"); 182 | assert(p4.parseValue(OptionFormat.duration) == [0, 0, 14, 16]); // 3600 seconds 183 | 184 | auto p5 = OptionParser("1d"); 185 | assert(p5.parseValue(OptionFormat.duration) == [0, 1, 81, 128]); // 1 day = 86400 seconds 186 | } 187 | } 188 | 189 | unittest 190 | { 191 | // Test classlessStaticRoute parsing and formatting 192 | { 193 | auto p = OptionParser("192.168.2.0/24 -> 192.168.1.50", true); // atTopLevel = true 194 | auto parsed = p.parseValue(OptionFormat.classlessStaticRoute); 195 | assert(parsed == [0x18, 0xc0, 0xa8, 0x02, 0xc0, 0xa8, 0x01, 0x32]); 196 | 197 | auto formatted = formatValue(parsed, OptionFormat.classlessStaticRoute); 198 | assert(formatted == "192.168.2.0/24 -> 192.168.1.50"); 199 | 200 | // Test roundtrip with all syntaxes 201 | testRoundtrip("192.168.2.0/24 -> 192.168.1.50", OptionFormat.classlessStaticRoute); 202 | } 203 | } 204 | 205 | unittest 206 | { 207 | // Test clientIdentifier parsing and formatting 208 | { 209 | auto p = OptionParser("type=1, clientIdentifier=AABBCCDDEEFF", true); // atTopLevel = true 210 | auto parsed = p.parseValue(OptionFormat.clientIdentifier); 211 | assert(parsed == [0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); 212 | 213 | auto formatted = formatValue(parsed, OptionFormat.clientIdentifier); 214 | assert(formatted == "type=1, clientIdentifier=AA BB CC DD EE FF"); 215 | 216 | // Test roundtrip with all syntaxes (including JSON validation) 217 | testRoundtrip("type=1, clientIdentifier=AABBCCDDEEFF", OptionFormat.clientIdentifier); 218 | } 219 | } 220 | 221 | unittest 222 | { 223 | // Test empty array handling 224 | { 225 | auto p1 = OptionParser("[]"); 226 | assert(p1.parseValue(OptionFormat.ips) == []); 227 | assert(formatValue([], OptionFormat.ips) == "[]"); 228 | 229 | auto p2 = OptionParser("[]"); 230 | assert(p2.parseValue(OptionFormat.u8s) == []); 231 | assert(formatValue([], OptionFormat.u8s) == "[]"); 232 | } 233 | 234 | // Test empty string 235 | { 236 | auto p = OptionParser("\"\""); 237 | assert(p.parseValue(OptionFormat.str) == []); 238 | assert(formatValue([], OptionFormat.str) == `""`); 239 | } 240 | 241 | // Test empty hex formatting 242 | { 243 | assert(formatValue([], OptionFormat.hex) == ""); 244 | } 245 | } 246 | 247 | unittest 248 | { 249 | // Test full uint range (was limited to signed int in old parser - TODO line 412) 250 | { 251 | auto p = OptionParser("4294967295"); // Max uint: 0xFFFFFFFF 252 | auto result = p.parseValue(OptionFormat.u32); 253 | assert(result == [0xFF, 0xFF, 0xFF, 0xFF]); 254 | 255 | // Round-trip 256 | auto formatted = formatValue(result, OptionFormat.u32); 257 | auto p2 = OptionParser(formatted); 258 | assert(p2.parseValue(OptionFormat.u32) == result); 259 | } 260 | } 261 | 262 | unittest 263 | { 264 | // Test edge case values 265 | { 266 | // u8 boundaries 267 | auto p1 = OptionParser("0"); 268 | assert(p1.parseValue(OptionFormat.u8) == [0]); 269 | 270 | auto p2 = OptionParser("255"); 271 | assert(p2.parseValue(OptionFormat.u8) == [255]); 272 | 273 | // u16 boundaries 274 | auto p3 = OptionParser("0"); 275 | assert(p3.parseValue(OptionFormat.u16) == [0, 0]); 276 | 277 | auto p4 = OptionParser("65535"); 278 | assert(p4.parseValue(OptionFormat.u16) == [0xFF, 0xFF]); 279 | 280 | // u32 boundaries 281 | auto p5 = OptionParser("0"); 282 | assert(p5.parseValue(OptionFormat.u32) == [0, 0, 0, 0]); 283 | } 284 | } 285 | 286 | unittest 287 | { 288 | // Test hex with spaces and colons (from formats.d line 693-695) 289 | { 290 | // Now works even without atTopLevel - only stops at delimiters 291 | auto p1 = OptionParser("DE AD BE EF"); 292 | assert(p1.parseValue(OptionFormat.hex) == [0xDE, 0xAD, 0xBE, 0xEF]); 293 | 294 | auto p2 = OptionParser("DE:AD:BE:EF"); 295 | assert(p2.parseValue(OptionFormat.hex) == [0xDE, 0xAD, 0xBE, 0xEF]); 296 | 297 | auto p3 = OptionParser("de-ad-be-ef"); 298 | assert(p3.parseValue(OptionFormat.hex) == [0xDE, 0xAD, 0xBE, 0xEF]); 299 | 300 | // Verify that values with spaces/separators work in embedded context 301 | // Example: parsing "Router Option" as dhcpOptionType without needing atTopLevel 302 | auto p4 = OptionParser("[Router Option, Domain Name Server Option]"); 303 | // This works because readPhrase only stops at ',' and ']', not whitespace 304 | } 305 | } 306 | 307 | unittest 308 | { 309 | // Test multi-IP round-trip (was TODO line 601-604: comma-space format) 310 | { 311 | auto bytes = cast(ubyte[])[192, 168, 1, 1, 10, 0, 0, 1]; 312 | auto formatted = formatValue(bytes, OptionFormat.ips); 313 | // Formatted as "[192.168.1.1, 10.0.0.1]" (comma-space) 314 | 315 | auto p = OptionParser(formatted); 316 | auto reparsed = p.parseValue(OptionFormat.ips); 317 | assert(reparsed == bytes, "Multi-IP round-trip failed"); 318 | } 319 | } 320 | 321 | unittest 322 | { 323 | // Test hex with ASCII round-trip (was TODO line 606-611: unparseable maybeAscii) 324 | { 325 | auto bytes = cast(ubyte[])"test"; 326 | auto formatted = formatValue(bytes, OptionFormat.hex); 327 | // New format: "74 65 73 74 (test)" - hex first, ASCII in comment 328 | assert(formatted == "74 65 73 74 (test)"); 329 | 330 | auto p = OptionParser(formatted); // No longer needs atTopLevel 331 | auto reparsed = p.parseValue(OptionFormat.hex); 332 | assert(reparsed == bytes, "Hex with ASCII round-trip failed"); 333 | } 334 | } 335 | 336 | unittest 337 | { 338 | // Test time with duration round-trip (was TODO line 633-637: unparseable duration) 339 | { 340 | auto bytes = cast(ubyte[])[0, 0, 14, 16]; // 3600 seconds 341 | auto formatted = formatValue(bytes, OptionFormat.duration); 342 | // New format: "3600 (1 hour)" - number first, duration in comment 343 | 344 | auto p = OptionParser(formatted); 345 | auto reparsed = p.parseValue(OptionFormat.duration); 346 | assert(reparsed == bytes, "Time with duration round-trip failed"); 347 | 348 | // Also test time unit input 349 | auto p2 = OptionParser("1h"); 350 | assert(p2.parseValue(OptionFormat.duration) == bytes); 351 | } 352 | } 353 | 354 | unittest 355 | { 356 | // Test trailing comma support (from formats.d line 726) 357 | { 358 | auto p = OptionParser("[1, 2, 3,]"); 359 | assert(p.parseValue(OptionFormat.u8s) == [1, 2, 3]); 360 | } 361 | } 362 | 363 | unittest 364 | { 365 | // Test u16 and u32 arrays (from formats.d lines 444-447) 366 | { 367 | auto p1 = OptionParser("[1000, 2000, 3000]"); 368 | assert(p1.parseValue(OptionFormat.u16s) == [0x03, 0xE8, 0x07, 0xD0, 0x0B, 0xB8]); 369 | 370 | auto p2 = OptionParser("[100000, 200000]"); 371 | assert(p2.parseValue(OptionFormat.u32s) == [0x00, 0x01, 0x86, 0xA0, 0x00, 0x03, 0x0D, 0x40]); 372 | } 373 | } 374 | 375 | unittest 376 | { 377 | // Test enum name parsing 378 | { 379 | // dhcpMessageType - enum member names 380 | auto p1 = OptionParser("discover"); 381 | assert(p1.parseValue(OptionFormat.dhcpMessageType) == [1]); 382 | 383 | auto p2 = OptionParser("offer"); 384 | assert(p2.parseValue(OptionFormat.dhcpMessageType) == [2]); 385 | 386 | // dhcpOptionType - numeric form (names from dhcpOptions table are full like "Router Option") 387 | auto p3 = OptionParser("3"); 388 | assert(p3.parseValue(OptionFormat.dhcpOptionType) == [3]); 389 | 390 | auto p4 = OptionParser("6"); 391 | assert(p4.parseValue(OptionFormat.dhcpOptionType) == [6]); 392 | 393 | auto p5 = OptionParser("53"); 394 | assert(p5.parseValue(OptionFormat.dhcpOptionType) == [53]); 395 | 396 | // Test full name from dhcpOptions table (now works without atTopLevel) 397 | auto p6 = OptionParser("Router Option"); 398 | assert(p6.parseValue(OptionFormat.dhcpOptionType) == [3]); 399 | } 400 | } 401 | 402 | unittest 403 | { 404 | // Test error handling: out of range values 405 | { 406 | import std.exception : assertThrown; 407 | 408 | // u8 out of range 409 | assertThrown(OptionParser("256").parseValue(OptionFormat.u8)); 410 | assertThrown(OptionParser("-1").parseValue(OptionFormat.u8)); 411 | 412 | // u16 out of range 413 | assertThrown(OptionParser("65536").parseValue(OptionFormat.u16)); 414 | assertThrown(OptionParser("-1").parseValue(OptionFormat.u16)); 415 | 416 | // u32 out of range 417 | assertThrown(OptionParser("4294967296").parseValue(OptionFormat.u32)); 418 | assertThrown(OptionParser("-1").parseValue(OptionFormat.u32)); 419 | } 420 | } 421 | 422 | unittest 423 | { 424 | // Test error handling: malformed input 425 | { 426 | import std.exception : assertThrown; 427 | 428 | // Malformed IP 429 | assertThrown(OptionParser("192.168.1").parseValue(OptionFormat.ip)); 430 | assertThrown(OptionParser("192.168.1.256").parseValue(OptionFormat.ip)); 431 | 432 | // Malformed hex 433 | assertThrown(OptionParser("GG").parseValue(OptionFormat.hex)); 434 | assertThrown(OptionParser("XYZ").parseValue(OptionFormat.hex)); 435 | 436 | // Malformed boolean 437 | assertThrown(OptionParser("maybe").parseValue(OptionFormat.boolean)); 438 | } 439 | } 440 | 441 | unittest 442 | { 443 | // Test strings with special characters 444 | { 445 | // Quoted string with brackets 446 | auto p1 = OptionParser(`"[test]"`); 447 | assert(p1.parseValue(OptionFormat.str) == cast(ubyte[])"[test]"); 448 | 449 | // Quoted string with comma 450 | auto p2 = OptionParser(`"value,with,commas"`); 451 | assert(p2.parseValue(OptionFormat.str) == cast(ubyte[])"value,with,commas"); 452 | 453 | // Quoted string with equals 454 | auto p3 = OptionParser(`"key=value"`); 455 | assert(p3.parseValue(OptionFormat.str) == cast(ubyte[])"key=value"); 456 | 457 | // Escaped characters in unquoted string 458 | auto p4 = OptionParser(`value\,with\,escaped`); 459 | assert(p4.parseValue(OptionFormat.str) == cast(ubyte[])"value,with,escaped"); 460 | } 461 | } 462 | 463 | unittest 464 | { 465 | // Test comments are properly stripped during parsing 466 | { 467 | auto p1 = OptionParser("42 (the answer)"); 468 | assert(p1.parseValue(OptionFormat.u8) == [42]); 469 | 470 | auto p2 = OptionParser("192.168.1.1 (gateway)"); 471 | assert(p2.parseValue(OptionFormat.ip) == [192, 168, 1, 1]); 472 | 473 | auto p3 = OptionParser("[1, 2, 3] (test array)"); 474 | assert(p3.parseValue(OptionFormat.u8s) == [1, 2, 3]); 475 | } 476 | } 477 | 478 | unittest 479 | { 480 | // Test leading whitespace is properly handled (was TODO formats.d:644-648) 481 | { 482 | // Single leading space 483 | auto p1 = OptionParser(" 42"); 484 | assert(p1.parseValue(OptionFormat.u8) == [42]); 485 | 486 | // Multiple leading spaces 487 | auto p2 = OptionParser(" 192.168.1.1"); 488 | assert(p2.parseValue(OptionFormat.ip) == [192, 168, 1, 1]); 489 | 490 | // Leading spaces with tabs 491 | auto p3 = OptionParser(" \t53"); 492 | assert(p3.parseValue(OptionFormat.dhcpOptionType) == [53]); 493 | 494 | // Leading spaces with comment (like formatDHCPOptionType output) 495 | auto p4 = OptionParser(" 53 (DHCP Message Type)"); 496 | assert(p4.parseValue(OptionFormat.dhcpOptionType) == [53]); 497 | 498 | // Array with leading spaces in elements 499 | auto p5 = OptionParser("[ 1 , 2 , 3 ]"); 500 | assert(p5.parseValue(OptionFormat.u8s) == [1, 2, 3]); 501 | } 502 | 503 | // Test that fullString format is NOT whitespace-insensitive (greedy mode) 504 | { 505 | auto p = OptionParser(" leading spaces", true); // atTopLevel = true 506 | auto result = cast(string)p.parseValue(OptionFormat.fullString); 507 | assert(result == " leading spaces", "fullString should preserve leading whitespace"); 508 | } 509 | } 510 | 511 | unittest 512 | { 513 | // Test space-separated arrays (backwards compatibility with old parser) 514 | { 515 | // Space-separated IPs 516 | auto p1 = OptionParser("192.168.1.1 10.0.0.1", true); // atTopLevel = true 517 | auto result1 = p1.parseValue(OptionFormat.ips); 518 | assert(result1 == [192, 168, 1, 1, 10, 0, 0, 1], "Space-separated IPs should work"); 519 | 520 | // Space-separated integers 521 | auto p2 = OptionParser("1 2 3", true); 522 | auto result2 = p2.parseValue(OptionFormat.u8s); 523 | assert(result2 == [1, 2, 3], "Space-separated u8s should work"); 524 | 525 | // Mixed space and comma separators 526 | auto p3 = OptionParser("1 2, 3 4", true); 527 | auto result3 = p3.parseValue(OptionFormat.u8s); 528 | assert(result3 == [1, 2, 3, 4], "Mixed space and comma separators should work"); 529 | 530 | // Comma-separated still works (backwards compatible) 531 | auto p4 = OptionParser("192.168.1.1,10.0.0.1", true); 532 | auto result4 = p4.parseValue(OptionFormat.ips); 533 | assert(result4 == [192, 168, 1, 1, 10, 0, 0, 1], "Comma-separated IPs should still work"); 534 | 535 | // With brackets 536 | auto p5 = OptionParser("[1 2 3]"); 537 | auto result5 = p5.parseValue(OptionFormat.u8s); 538 | assert(result5 == [1, 2, 3], "Space-separated with brackets should work"); 539 | 540 | // u16 and u32 space-separated 541 | auto p6 = OptionParser("1000 2000 3000", true); 542 | auto result6 = p6.parseValue(OptionFormat.u16s); 543 | assert(result6 == [0x03, 0xE8, 0x07, 0xD0, 0x0B, 0xB8], "Space-separated u16s should work"); 544 | } 545 | } 546 | 547 | unittest 548 | { 549 | // Test TLV list types: relayAgent and vendorSpecificInformation 550 | // Comprehensive tests ported from options.d RelayAgentInformation unittest (lines 256-292) 551 | void testRelayAgent(ubyte[] bytes, string str) 552 | { 553 | // Format bytes to string (minimal syntax) 554 | auto formatted = formatValue(bytes, OptionFormat.relayAgent); 555 | assert(formatted == str, format("Format failed: expected %s, got %s", str, formatted)); 556 | 557 | // Parse string to bytes 558 | auto p = OptionParser(str, true); 559 | auto parsed = p.parseValue(OptionFormat.relayAgent); 560 | assert(parsed == bytes, format("Parse failed: expected %s, got %s", bytes, parsed)); 561 | 562 | // Test roundtrip with all syntaxes (including JSON validation) 563 | testRoundtrip(str, OptionFormat.relayAgent); 564 | } 565 | 566 | // Empty 567 | testRelayAgent([], ``); 568 | 569 | // Raw suffix - single unparseable byte (raw-suffix feature) 570 | testRelayAgent([0x00], `raw="\0"`); 571 | 572 | // Single suboption 573 | testRelayAgent([0x01, 0x03, 'f', 'o', 'o'], `agentCircuitID=foo`); 574 | 575 | // Suboption followed by raw suffix (unparseable trailing byte) 576 | testRelayAgent([0x01, 0x03, 'f', 'o', 'o', 0x42], `agentCircuitID=foo, raw=B`); 577 | 578 | // Multiple suboptions 579 | testRelayAgent( 580 | [0x01, 0x03, 'f', 'o', 'o', 0x02, 0x03, 'b', 'a', 'r'], 581 | `agentCircuitID=foo, agentRemoteID=bar` 582 | ); 583 | 584 | // Unknown suboption type (numeric) 585 | testRelayAgent([0x03, 0x03, 'f', 'o', 'o'], `3=foo`); 586 | 587 | // Test from formats.d line 656-657 588 | testRelayAgent([0x01, 0x04, 't', 'e', 's', 't'], `agentCircuitID=test`); 589 | } 590 | 591 | unittest 592 | { 593 | // Test vendorSpecificInformation (from formats.d lines 659-661) 594 | void testVendor(ubyte[] bytes, string str) 595 | { 596 | // Format bytes to string (minimal syntax) 597 | auto formatted = formatValue(bytes, OptionFormat.vendorSpecificInformation); 598 | assert(formatted == str, format("Format failed: expected %s, got %s", str, formatted)); 599 | 600 | // Parse string to bytes 601 | auto p = OptionParser(str, true); 602 | auto parsed = p.parseValue(OptionFormat.vendorSpecificInformation); 603 | assert(parsed == bytes, format("Parse failed: expected %s, got %s", bytes, parsed)); 604 | 605 | // Test roundtrip with all syntaxes (including JSON validation) 606 | testRoundtrip(str, OptionFormat.vendorSpecificInformation); 607 | } 608 | 609 | // Test from formats.d 610 | testVendor([0x01, 0x05, 'v', 'a', 'l', 'u', 'e'], `1=value`); 611 | 612 | // Empty 613 | testVendor([], ``); 614 | 615 | // Raw suffix - printable character 616 | testVendor([0x42], `raw=B`); 617 | 618 | // Raw suffix - null byte (from options.d test) 619 | testVendor([0x00], `raw="\0"`); 620 | 621 | // Raw suffix - invalid UTF-8 byte (should be properly escaped) 622 | testVendor([0xFF], `raw="\xFF"`); 623 | } 624 | 625 | unittest 626 | { 627 | // Test backwards compatibility wrappers 628 | import std.algorithm : equal; 629 | 630 | // Test parseOption wrapper 631 | auto bytes1 = parseOption("192.168.1.1", OptionFormat.ip); 632 | assert(bytes1.equal([192, 168, 1, 1])); 633 | 634 | auto bytes2 = parseOption("foo bar", OptionFormat.str); 635 | assert(cast(string)bytes2 == "foo bar"); 636 | 637 | auto bytes3 = parseOption("1 2 3", OptionFormat.u8s); 638 | assert(bytes3.equal([1, 2, 3])); 639 | 640 | // Test formatOption wrapper (alias to formatValue) 641 | alias formatOption = formatValue; 642 | assert(formatOption([192, 168, 1, 1], OptionFormat.ip) == "192.168.1.1"); 643 | assert(formatOption([1, 2, 3], OptionFormat.u8s) == "[1, 2, 3]"); 644 | 645 | // Test formatRawOption (no comment) 646 | string formatRawOption(in ubyte[] bytes, OptionFormat fmt) 647 | { 648 | return formatValue(bytes, fmt); 649 | } 650 | assert(formatRawOption([42], OptionFormat.u8) == "42"); 651 | 652 | // Test deprecated aliases 653 | auto bytes4 = parseOption("10.0.0.1", OptionFormat.IP); // Deprecated alias 654 | assert(bytes4.equal([10, 0, 0, 1])); 655 | } 656 | 657 | unittest 658 | { 659 | // Test format overrides in struct parsing 660 | import std.algorithm : equal; 661 | 662 | // Test clientIdentifier with format overrides 663 | { 664 | auto p = OptionParser("type=1, clientIdentifier[hex]=AABBCCDD", true); 665 | auto bytes = p.parseClientIdentifier(); 666 | assert(bytes.equal([1, 0xAA, 0xBB, 0xCC, 0xDD])); 667 | } 668 | 669 | // Test with different override - type as hex 670 | { 671 | auto p = OptionParser("type[hex]=01, clientIdentifier=DEADBEEF", true); 672 | auto bytes = p.parseClientIdentifier(); 673 | assert(bytes.equal([0x01, 0xDE, 0xAD, 0xBE, 0xEF])); 674 | } 675 | 676 | // Test TLV with format override - parse value as hex instead of string 677 | { 678 | auto p = OptionParser("agentCircuitID[hex]=DEADBEEF", true); 679 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 680 | assert(bytes.equal([0x01, 0x04, 0xDE, 0xAD, 0xBE, 0xEF])); 681 | } 682 | 683 | // Test multiple fields with different overrides 684 | { 685 | auto p = OptionParser("type[u8]=42, clientIdentifier[hex]=CAFEBABE", true); 686 | auto bytes = p.parseClientIdentifier(); 687 | assert(bytes.equal([42, 0xCA, 0xFE, 0xBA, 0xBE])); 688 | } 689 | 690 | // Test array format override (arrays require brackets in struct context) 691 | { 692 | auto p = OptionParser("agentCircuitID[u8s]=[1, 2, 3], agentRemoteID=foo", true); 693 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 694 | // agentCircuitID (type=1): length=3, values=[1,2,3] 695 | // agentRemoteID (type=2): length=3, values="foo" 696 | assert(bytes.equal([0x01, 0x03, 1, 2, 3, 0x02, 0x03, 'f', 'o', 'o'])); 697 | } 698 | 699 | // Test with brackets (embedded syntax) 700 | { 701 | auto p = OptionParser("[type=1, clientIdentifier[hex]=FF]", false); 702 | auto bytes = p.parseClientIdentifier(); 703 | assert(bytes.equal([1, 0xFF])); 704 | } 705 | 706 | // Test ip format override (scalar, no brackets needed) 707 | { 708 | auto p = OptionParser("agentCircuitID[ip]=192.168.1.1", true); 709 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 710 | assert(bytes.equal([0x01, 0x04, 192, 168, 1, 1])); 711 | } 712 | 713 | // Test ips (array) format override (array requires brackets) 714 | { 715 | auto p = OptionParser("agentCircuitID[ips]=[192.168.1.1, 10.0.0.1]", true); 716 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 717 | assert(bytes.equal([0x01, 0x08, 192, 168, 1, 1, 10, 0, 0, 1])); 718 | } 719 | 720 | // Test boolean override 721 | { 722 | auto p = OptionParser("agentCircuitID[boolean]=true", true); 723 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 724 | assert(bytes.equal([0x01, 0x01, 1])); 725 | } 726 | 727 | // Test u16 override 728 | { 729 | auto p = OptionParser("agentCircuitID[u16]=1000", true); 730 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 731 | assert(bytes.equal([0x01, 0x02, 0x03, 0xE8])); // 1000 in network byte order 732 | } 733 | 734 | // Test u32 override 735 | { 736 | auto p = OptionParser("agentCircuitID[u32]=4294967295", true); 737 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 738 | assert(bytes.equal([0x01, 0x04, 0xFF, 0xFF, 0xFF, 0xFF])); 739 | } 740 | 741 | // Test duration override 742 | { 743 | auto p = OptionParser("agentCircuitID[duration]=1h", true); 744 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 745 | assert(bytes.equal([0x01, 0x04, 0x00, 0x00, 0x0E, 0x10])); // 3600 in network byte order 746 | } 747 | } 748 | 749 | // Test parseOption validation - should consume entire input 750 | unittest 751 | { 752 | import std.exception : assertThrown; 753 | 754 | // Valid - entire input consumed 755 | assert(parseOption("192.168.1.1", OptionFormat.ip).equal([192, 168, 1, 1])); 756 | assert(parseOption("42", OptionFormat.u8).equal([42])); 757 | 758 | // Invalid - trailing input after valid value 759 | // Note: These only fail if the format doesn't consume the entire input 760 | assertThrown(parseOption("192.168.1.1extra", OptionFormat.ip)); // Invalid IP format 761 | assertThrown(parseOption("42garbage", OptionFormat.u8)); // Invalid number 762 | assertThrown(parseOption("true false", OptionFormat.boolean)); // Extra input after boolean 763 | } 764 | 765 | // Test roundtrip with all syntax modes for various types 766 | unittest 767 | { 768 | // Test various types (now tests both minimal and JSON syntax) 769 | testRoundtrip("192.168.1.1", OptionFormat.ip); 770 | testRoundtrip("42", OptionFormat.u8); 771 | testRoundtrip("true", OptionFormat.boolean); 772 | testRoundtrip("false", OptionFormat.boolean); 773 | testRoundtrip("3600", OptionFormat.duration); 774 | testRoundtrip("testhost", OptionFormat.str); 775 | testRoundtrip("DEADBEEF", OptionFormat.hex); 776 | } 777 | 778 | // Test JSON syntax parsing 779 | unittest 780 | { 781 | // Test JSON-style field separator (:) 782 | { 783 | auto p = OptionParser(`{"type": 1, "clientIdentifier": "AABBCCDDEE"}`, true); 784 | auto bytes = p.parseClientIdentifier(); 785 | assert(bytes.equal([0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE])); 786 | } 787 | 788 | // Test quoted field names 789 | { 790 | auto p = OptionParser(`{"agentCircuitID": "test"}`, true); 791 | auto bytes = p.parseTLVList!RelayAgentSuboption(); 792 | assert(bytes[0] == 0x01); // agentCircuitID type 793 | assert(bytes[1] == 4); // length 794 | assert(bytes[2..6] == cast(ubyte[])"test"); 795 | } 796 | } 797 | 798 | // Test which formats produce valid JSON in JSON mode 799 | unittest 800 | { 801 | import std.json; 802 | import std.exception : assertThrown, assertNotThrown; 803 | 804 | // Formats that should produce valid JSON 805 | { 806 | // Scalar types - produce quoted strings 807 | assertNotThrown(parseJSON(formatValue([42], OptionFormat.u8, Syntax.json), JSONOptions.strictParsing)); 808 | assertNotThrown(parseJSON(formatValue([1], OptionFormat.boolean, Syntax.json), JSONOptions.strictParsing)); 809 | assertNotThrown(parseJSON(formatValue([192, 168, 1, 1], OptionFormat.ip, Syntax.json), JSONOptions.strictParsing)); 810 | assertNotThrown(parseJSON(formatValue(cast(ubyte[])"test", OptionFormat.str, Syntax.json), JSONOptions.strictParsing)); 811 | assertNotThrown(parseJSON(formatValue([0xDE, 0xAD], OptionFormat.hex, Syntax.json), JSONOptions.strictParsing)); 812 | assertNotThrown(parseJSON(formatValue([3], OptionFormat.dhcpOptionType, Syntax.json), JSONOptions.strictParsing)); 813 | 814 | // Array types - produce JSON arrays 815 | assertNotThrown(parseJSON(formatValue([1, 2, 3], OptionFormat.u8s, Syntax.json), JSONOptions.strictParsing)); 816 | assertNotThrown(parseJSON(formatValue([192, 168, 1, 1, 10, 0, 0, 1], OptionFormat.ips, Syntax.json), JSONOptions.strictParsing)); 817 | assertNotThrown(parseJSON(formatValue([3, 6], OptionFormat.dhcpOptionTypes, Syntax.json), JSONOptions.strictParsing)); 818 | 819 | // Struct types - produce JSON objects 820 | assertNotThrown(parseJSON(formatValue([0x01, 0xAA, 0xBB], OptionFormat.clientIdentifier, Syntax.json), JSONOptions.strictParsing)); 821 | assertNotThrown(parseJSON(formatValue([0x01, 0x04, 't', 'e', 's', 't'], OptionFormat.relayAgent, Syntax.json), JSONOptions.strictParsing)); 822 | 823 | // Special types - produce JSON arrays 824 | assertNotThrown(parseJSON(formatValue([0x18, 0xc0, 0xa8, 0x02, 0xc0, 0xa8, 0x01, 0x32], OptionFormat.classlessStaticRoute, Syntax.json), JSONOptions.strictParsing)); 825 | } 826 | 827 | // Verify classlessStaticRoute now produces valid JSON 828 | { 829 | // classlessStaticRoute - now uses array syntax in JSON mode 830 | auto routeBytes = cast(ubyte[])[0x18, 0xc0, 0xa8, 0x02, 0xc0, 0xa8, 0x01, 0x32]; 831 | auto routeMinimal = formatValue(routeBytes, OptionFormat.classlessStaticRoute, Syntax.verbose); 832 | auto routeJson = formatValue(routeBytes, OptionFormat.classlessStaticRoute, Syntax.json); 833 | 834 | assert(routeMinimal == "192.168.2.0/24 -> 192.168.1.50"); 835 | assert(routeJson == `[["192.168.2.0/24", "192.168.1.50"]]`); 836 | 837 | // Verify JSON is valid 838 | assertNotThrown(parseJSON(routeJson, JSONOptions.strictParsing)); 839 | 840 | // Test parsing both formats 841 | auto parsedArrow = parseOption("192.168.2.0/24 -> 192.168.1.50", OptionFormat.classlessStaticRoute); 842 | auto parsedArray = parseOption(`[["192.168.2.0/24", "192.168.1.50"]]`, OptionFormat.classlessStaticRoute); 843 | assert(parsedArrow == routeBytes); 844 | assert(parsedArray == routeBytes); 845 | } 846 | 847 | // Verify proper output format for dhcpOptionType (now fixed to be syntax-aware) 848 | { 849 | // dhcpOptionType - now produces valid JSON with unquoted numbers 850 | auto optionMinimal = formatValue(cast(ubyte[])[3], OptionFormat.dhcpOptionType, Syntax.verbose); 851 | auto optionJson = formatValue(cast(ubyte[])[3], OptionFormat.dhcpOptionType, Syntax.json); 852 | assert(optionMinimal == "3 (Router Option)"); 853 | assert(optionJson == "3"); 854 | 855 | // dhcpOptionTypes - now produces valid JSON with unquoted numbers 856 | auto optionsMinimal = formatValue(cast(ubyte[])[3, 6], OptionFormat.dhcpOptionTypes, Syntax.verbose); 857 | auto optionsJson = formatValue(cast(ubyte[])[3, 6], OptionFormat.dhcpOptionTypes, Syntax.json); 858 | assert(optionsMinimal == "[3 (Router Option), 6 (Domain Name Server Option)]"); 859 | assert(optionsJson == "[3, 6]"); 860 | } 861 | } 862 | 863 | // ============================================================================ 864 | // Error Handling Tests 865 | // ============================================================================ 866 | 867 | /// Test graceful error handling with hex fallback 868 | unittest 869 | { 870 | import std.array : appender; 871 | import std.string : indexOf; 872 | 873 | // Test 1: formatValue (standalone) should throw on malformed data 874 | { 875 | // 5 bytes for IP array (not divisible by 4) 876 | ubyte[] badIPArray = [192, 168, 1, 1, 5]; 877 | assertThrown!Exception(formatValue(badIPArray, OptionFormat.ips)); 878 | 879 | // 3 bytes for single IP (should be 4) 880 | ubyte[] badIP = [192, 168, 1]; 881 | assertThrown!Exception(formatValue(badIP, OptionFormat.ip)); 882 | 883 | // 3 bytes for u16 array (not divisible by 2) 884 | ubyte[] badU16Array = [0x00, 0x01, 0x02]; 885 | assertThrown!Exception(formatValue(badU16Array, OptionFormat.u16s)); 886 | } 887 | 888 | // Test 2: formatValue with warning callback should throw (but can report via callback) 889 | { 890 | bool warningCalled = false; 891 | void warningHandler(string msg) { warningCalled = true; } 892 | 893 | ubyte[] badIPArray = [192, 168, 1, 1, 5]; 894 | 895 | // Should still throw even with warning callback 896 | assertThrown!Exception(formatValue(badIPArray, OptionFormat.ips, Syntax.verbose, &warningHandler)); 897 | 898 | // Warning callback not invoked for top-level formatValue failures 899 | assert(!warningCalled); 900 | } 901 | 902 | // Test 3: OptionFormatter with formatField should fall back to hex 903 | { 904 | import dhcptest.formats.formatting : OptionFormatter; 905 | 906 | bool warningCalled = false; 907 | string warningMsg; 908 | void warningHandler(string msg) 909 | { 910 | warningCalled = true; 911 | warningMsg = msg; 912 | } 913 | 914 | auto buf = appender!string; 915 | auto formatter = OptionFormatter!(typeof(buf))(buf, Syntax.verbose, &warningHandler); 916 | 917 | // Create a malformed struct-like value with a bad IP field 918 | // We'll use the internal formatField method behavior through TLV formatting 919 | // For now, just verify that hex fallback works through the public API 920 | 921 | // This will be tested more thoroughly through packet formatting tests 922 | } 923 | 924 | // Test 4: Verify hex format never throws (safety net) 925 | { 926 | // Empty data 927 | assert(formatValue([], OptionFormat.hex) == ""); 928 | 929 | // Any random bytes should format as hex 930 | ubyte[] randomBytes = [0xDE, 0xAD, 0xBE, 0xEF, 0x12, 0x34]; 931 | auto hexStr = formatValue(randomBytes, OptionFormat.hex); 932 | assert(hexStr == "DE AD BE EF 12 34"); 933 | 934 | // Even "malformed" data for other formats works fine as hex 935 | ubyte[] badIPArray = [192, 168, 1, 1, 5]; 936 | hexStr = formatValue(badIPArray, OptionFormat.hex); 937 | assert(hexStr == "C0 A8 01 01 05"); 938 | } 939 | 940 | // Test 5: Packet formatting with malformed options 941 | { 942 | import dhcptest.packets : DHCPPacket, DHCPOption, formatPacket; 943 | 944 | bool warningCalled = false; 945 | string[] warnings; 946 | void warningHandler(string msg) 947 | { 948 | warningCalled = true; 949 | warnings ~= msg; 950 | } 951 | 952 | DHCPPacket packet; 953 | packet.header.op = 2; // BOOTREPLY 954 | packet.header.xid = 0x12345678; 955 | 956 | // Add malformed option (5 bytes for an IP, which needs 4) 957 | packet.options ~= DHCPOption(1, [255, 255, 255, 0, 1]); // Subnet mask with extra byte 958 | 959 | // Add valid option 960 | packet.options ~= DHCPOption(3, [192, 168, 1, 1]); // Router - valid 961 | 962 | auto packetStr = formatPacket(packet, null, &warningHandler); 963 | 964 | // Should have emitted a warning 965 | assert(warningCalled, "Should have emitted warning for malformed option"); 966 | assert(warnings.length > 0, "Should have at least one warning"); 967 | 968 | // Warning should mention the error 969 | assert(warnings[0].indexOf("IP address must be 4 bytes") >= 0, 970 | "Warning should mention the specific error"); 971 | 972 | // Packet should contain both options 973 | assert(packetStr.indexOf("1") >= 0, "Should contain option 1"); 974 | assert(packetStr.indexOf("3") >= 0, "Should contain option 3"); 975 | 976 | // Malformed option should have [hex] annotation 977 | assert(packetStr.indexOf("[hex]") >= 0, "Should show [hex] for malformed option"); 978 | 979 | // Malformed option should show hex bytes 980 | assert(packetStr.indexOf("FF FF FF 00 01") >= 0, "Should show hex bytes for malformed option"); 981 | 982 | // Valid option should display correctly 983 | assert(packetStr.indexOf("192.168.1.1") >= 0, "Valid option should display correctly"); 984 | } 985 | } 986 | --------------------------------------------------------------------------------