ARPパケットの解析とバイト列の組み立て
ARPパケットの解析・組み立てを実装しました。生バイト列からの構造化に加えて、送信用バイト列を組み立てる方向も扱います。
ARPパケットの構造
ARPパケット(Ethernet上でIPv4を解決する場合)は28バイトの固定フォーマットです。
| フィールド | サイズ | 内容 |
|---|---|---|
| Hardware Type | 2バイト | 1 = Ethernet |
| Protocol Type | 2バイト | 0x0800 = IPv4 |
| Hardware Address Length | 1バイト | 6 |
| Protocol Address Length | 1バイト | 4 |
| Operation | 2バイト | 1 = Request、2 = Reply |
| Sender MAC | 6バイト | |
| Sender IP | 4バイト | |
| Target MAC | 6バイト | Requestでは全て0 |
| Target IP | 4バイト |
Ethernet IIフレームと違い可変長のペイロードを持たないため、Ethernet解析で使ったsubspanのパターンがそのまま使えます。
型設計とパース
// l2-arp/include/l2arp/arp.hpp
using Ipv4Address = std::array<std::byte, 4>;
std::string to_string(const Ipv4Address& ip);
enum class ArpOperation : std::uint16_t {
Request = 1,
Reply = 2,
};
struct ArpPacket {
ArpOperation operation;
MacAddress sender_mac;
Ipv4Address sender_ip;
MacAddress target_mac;
Ipv4Address target_ip;
};
enum class ArpParseError {
PacketTooShort,
UnsupportedHardwareType,
UnsupportedProtocolType,
UnsupportedAddressLength,
};
std::expected<ArpPacket, ArpParseError> parse_arp_packet(std::span<const std::byte> packet);エラー型を4種類に分けているのがEthernet解析との違いです。ARPはHardware Type・Protocol Type・アドレス長のいずれかが想定外だと、その先のバイト列をどう解釈すべきか原理的に決められません。単に「パース失敗」の1値にまとめず、どの前提が崩れていたかを呼び出し側が判別できる形にしています。
// l2-arp/src/arp.cpp
std::expected<ArpPacket, ArpParseError> parse_arp_packet(std::span<const std::byte> packet) {
if (packet.size() < HeaderSize) {
return std::unexpected(ArpParseError::PacketTooShort);
}
if (read_uint16(packet.subspan<0, 2>()) != HardwareTypeEthernet) {
return std::unexpected(ArpParseError::UnsupportedHardwareType);
}
if (read_uint16(packet.subspan<2, 2>()) != ProtocolTypeIpv4) {
return std::unexpected(ArpParseError::UnsupportedProtocolType);
}
if (packet[4] != HardwareAddrLenEthernet || packet[5] != ProtocolAddrLenIpv4) {
return std::unexpected(ArpParseError::UnsupportedAddressLength);
}
return ArpPacket{
.operation = static_cast<ArpOperation>(read_uint16(packet.subspan<6, 2>())),
.sender_mac = read_mac(packet.subspan<8, 6>()),
.sender_ip = read_ipv4(packet.subspan<14, 4>()),
.target_mac = read_mac(packet.subspan<18, 6>()),
.target_ip = read_ipv4(packet.subspan<24, 4>()),
};
}バイト列を組み立てる:build_arp_request / build_arp_reply
パースと逆方向、送信用のARPパケットを組み立てる関数も用意しました。Request/Replyの違いは「operationの値」と「target_macを埋めるかどうか」だけなので、共通のprivateヘルパーに委譲しています。
// l2-arp/src/arp.cpp(抜粋)
ArpPayload build_arp_request(
const MacAddress& sender_mac,
const Ipv4Address& sender_ip,
const Ipv4Address& target_ip
) {
return build_arp_payload(ArpOperation::Request, sender_mac, sender_ip, MacAddress{}, target_ip);
}
ArpPayload build_arp_reply(
const MacAddress& sender_mac,
const Ipv4Address& sender_ip,
const MacAddress& target_mac,
const Ipv4Address& target_ip
) {
return build_arp_payload(ArpOperation::Reply, sender_mac, sender_ip, target_mac, target_ip);
}パースと組み立てをラウンドトリップでテストする
テストは「組み立てたバイト列をparse_arp_packetで読み戻し、元の値と一致するか」というラウンドトリップ検証にしました。
// l2-arp/tests/arp_parse_test.cpp(抜粋)
TEST_CASE("build_arp_request: operation and target_mac are correct") {
auto sender_mac = mac(std::byte{0x00}, std::byte{0x11}, std::byte{0x22}, std::byte{0x33}, std::byte{0x44}, std::byte{0x55});
auto sender_ip = ipv4(std::byte{192}, std::byte{168}, std::byte{1}, std::byte{1});
auto target_ip = ipv4(std::byte{192}, std::byte{168}, std::byte{1}, std::byte{2});
auto payload = l2arp::build_arp_request(sender_mac, sender_ip, target_ip);
auto parsed = l2arp::parse_arp_packet(payload);
REQUIRE(parsed.has_value());
CHECK(parsed->operation == l2arp::ArpOperation::Request);
CHECK(parsed->sender_mac == sender_mac);
CHECK(parsed->sender_ip == sender_ip);
CHECK(parsed->target_mac == l2arp::MacAddress{});
CHECK(parsed->target_ip == target_ip);
}パースと組み立てを別々にテストするより、双方の実装が矛盾なく対になっていることまで一度に確認できるのがこの形の利点です。合成バイト列を使うパース単体のテストと合わせて、実ネットワークやroot権限を必要とせずにロジックの正しさを検証できます。
参考リンク
- RFC 826: An Ethernet Address Resolution Protocol —— ARPパケットフォーマットの一次情報源です
- cppreference: std::expected ——
std::unexpectedとの組み合わせ方を確認する際に参照します