From d7a1ee28166c8d119007f10aeaf953015e2b92ad Mon Sep 17 00:00:00 2001 From: p-keminer Date: Sun, 9 Aug 2026 07:48:10 +0200 Subject: [PATCH 1/2] feat: add SD-backed offline OUI labels --- .gitignore | 3 + esp32_marauder/MarauderOui.cpp | 246 +++++++++++++ esp32_marauder/MarauderOui.h | 94 +++++ esp32_marauder/MarauderOuiSd.cpp | 141 ++++++++ esp32_marauder/MarauderOuiSd.h | 56 +++ esp32_marauder/MenuFunctions.cpp | 85 ++++- esp32_marauder/WiFiScan.cpp | 17 + esp32_marauder/configs.h | 1 + platformio.ini | 1 + test/test_oui_lookup/test_main.cpp | 350 ++++++++++++++++++ tools/OUI_DATABASE.md | 140 ++++++++ tools/build_oui_database.py | 553 +++++++++++++++++++++++++++++ tools/test_build_oui_database.py | 323 +++++++++++++++++ 13 files changed, 1995 insertions(+), 15 deletions(-) create mode 100644 esp32_marauder/MarauderOui.cpp create mode 100644 esp32_marauder/MarauderOui.h create mode 100644 esp32_marauder/MarauderOuiSd.cpp create mode 100644 esp32_marauder/MarauderOuiSd.h create mode 100644 test/test_oui_lookup/test_main.cpp create mode 100644 tools/OUI_DATABASE.md create mode 100644 tools/build_oui_database.py create mode 100644 tools/test_build_oui_database.py diff --git a/.gitignore b/.gitignore index f4e6f4f60..d1f479d91 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ esp32_marauder/.vscode/settings.json __pycache__/ *.pyc + +# Generated offline IEEE assignment database +marauder_oui.bin diff --git a/esp32_marauder/MarauderOui.cpp b/esp32_marauder/MarauderOui.cpp new file mode 100644 index 000000000..85d38f3b8 --- /dev/null +++ b/esp32_marauder/MarauderOui.cpp @@ -0,0 +1,246 @@ +#include "MarauderOui.h" + +#include +#include + +namespace marauder { +namespace { + +constexpr uint8_t kOuiMagic[8] = {'M', 'R', 'O', 'U', + 'I', '0', '0', '1'}; +constexpr uint8_t kOuiFormatVersion = 1; +constexpr size_t kOuiCount24Offset = 12; +constexpr size_t kOuiCount28Offset = 16; +constexpr size_t kOuiCount36Offset = 20; + +uint32_t readUint32LittleEndian(const uint8_t* input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8) | + (static_cast(input[2]) << 16) | + (static_cast(input[3]) << 24); +} + +bool isAll(const uint8_t mac[kOuiMacAddressSize], uint8_t value) { + for (size_t index = 0; index < kOuiMacAddressSize; ++index) { + if (mac[index] != value) { + return false; + } + } + return true; +} + +void clearResult(OuiLookupResult& result) { + result.classification = OuiClassification::kUnknown; + result.prefix_length = 0; + memset(result.vendor, 0, sizeof(result.vendor)); +} + +void makePrefixKey(const uint8_t mac[kOuiMacAddressSize], + uint8_t prefix_length, + uint8_t key[kOuiPrefixKeySize]) { + memset(key, 0, kOuiPrefixKeySize); + + const size_t whole_bytes = prefix_length / 8; + memcpy(key, mac, whole_bytes); + if ((prefix_length % 8) != 0) { + const uint8_t partial_mask = + static_cast(0xFFU << (8 - (prefix_length % 8))); + key[whole_bytes] = mac[whole_bytes] & partial_mask; + } +} + +int compareKeys(const uint8_t left[kOuiPrefixKeySize], + const uint8_t right[kOuiPrefixKeySize]) { + for (size_t index = 0; index < kOuiPrefixKeySize; ++index) { + if (left[index] < right[index]) { + return -1; + } + if (left[index] > right[index]) { + return 1; + } + } + return 0; +} + +bool copyValidName(const uint8_t raw_name[kOuiNameSize], + char destination[kOuiNameSize]) { + size_t length = 0; + while (length < kOuiNameSize && raw_name[length] != 0) { + if (raw_name[length] < 0x20 || raw_name[length] > 0x7E) { + return false; + } + ++length; + } + + if (length == 0 || length == kOuiNameSize) { + return false; + } + + memset(destination, 0, kOuiNameSize); + memcpy(destination, raw_name, length); + return true; +} + +} // namespace + +OuiClassification classifyOuiMac( + const uint8_t mac[kOuiMacAddressSize]) { + if (mac == nullptr || isAll(mac, 0x00)) { + return OuiClassification::kInvalid; + } + if (isAll(mac, 0xFF)) { + return OuiClassification::kBroadcast; + } + if ((mac[0] & 0x01U) != 0) { + return OuiClassification::kMulticast; + } + if ((mac[0] & 0x02U) != 0) { + return OuiClassification::kLocal; + } + return OuiClassification::kUnknown; +} + +MacIdentity identifyMacAddress( + const uint8_t mac[kOuiMacAddressSize], + const OuiByteReader* reader) { + MacIdentity identity = {}; + identity.classification = classifyOuiMac(mac); + if (identity.classification != OuiClassification::kUnknown) { + return identity; + } + + OuiDatabase database; + if (database.open(reader) != OuiOpenStatus::kReady || + database.lookup(mac, identity) != OuiLookupStatus::kSuccess) { + clearResult(identity); + } + return identity; +} + +OuiDatabase::OuiDatabase() : reader_(nullptr), sections_{} {} + +OuiOpenStatus OuiDatabase::open(const OuiByteReader* reader) { + close(); + if (reader == nullptr) { + return OuiOpenStatus::kInvalidArgument; + } + if (reader->size() < kOuiHeaderSize) { + return OuiOpenStatus::kInvalidFormat; + } + + uint8_t header[kOuiHeaderSize] = {}; + if (!reader->read(0, header, sizeof(header))) { + return OuiOpenStatus::kReadError; + } + + if (memcmp(header, kOuiMagic, sizeof(kOuiMagic)) != 0 || + header[8] != kOuiFormatVersion || header[9] != kOuiRecordSize || + header[10] != kOuiNameSize || header[11] != 0) { + return OuiOpenStatus::kInvalidFormat; + } + + const uint32_t counts[3] = { + readUint32LittleEndian(header + kOuiCount24Offset), + readUint32LittleEndian(header + kOuiCount28Offset), + readUint32LittleEndian(header + kOuiCount36Offset), + }; + const uint8_t prefix_lengths[3] = {24, 28, 36}; + + uint64_t next_offset = kOuiHeaderSize; + for (size_t index = 0; index < 3; ++index) { + if (next_offset > static_cast(SIZE_MAX)) { + return OuiOpenStatus::kInvalidFormat; + } + sections_[index].offset = static_cast(next_offset); + sections_[index].count = counts[index]; + sections_[index].prefix_length = prefix_lengths[index]; + next_offset += static_cast(counts[index]) * kOuiRecordSize; + } + + if (next_offset != static_cast(reader->size())) { + close(); + return OuiOpenStatus::kInvalidFormat; + } + + reader_ = reader; + return OuiOpenStatus::kReady; +} + +void OuiDatabase::close() { + reader_ = nullptr; + memset(sections_, 0, sizeof(sections_)); +} + +bool OuiDatabase::isOpen() const { return reader_ != nullptr; } + +OuiLookupStatus OuiDatabase::lookup( + const uint8_t mac[kOuiMacAddressSize], OuiLookupResult& result) const { + clearResult(result); + result.classification = classifyOuiMac(mac); + if (result.classification != OuiClassification::kUnknown) { + return OuiLookupStatus::kSuccess; + } + if (reader_ == nullptr) { + return OuiLookupStatus::kDatabaseNotOpen; + } + + for (size_t reverse_index = 3; reverse_index > 0; --reverse_index) { + const Section& section = sections_[reverse_index - 1]; + uint8_t key[kOuiPrefixKeySize] = {}; + makePrefixKey(mac, section.prefix_length, key); + + bool found = false; + const OuiLookupStatus status = + findInSection(section, key, result, found); + if (status != OuiLookupStatus::kSuccess) { + return status; + } + if (found) { + result.classification = OuiClassification::kVendor; + result.prefix_length = section.prefix_length; + return OuiLookupStatus::kSuccess; + } + } + + return OuiLookupStatus::kSuccess; +} + +OuiLookupStatus OuiDatabase::findInSection( + const Section& section, const uint8_t key[kOuiPrefixKeySize], + OuiLookupResult& result, bool& found) const { + found = false; + uint32_t lower = 0; + uint32_t upper = section.count; + + while (lower < upper) { + const uint32_t middle = lower + ((upper - lower) / 2); + const size_t record_offset = + section.offset + static_cast(middle) * kOuiRecordSize; + uint8_t record_key[kOuiPrefixKeySize] = {}; + if (!reader_->read(record_offset, record_key, sizeof(record_key))) { + return OuiLookupStatus::kReadError; + } + + const int comparison = compareKeys(record_key, key); + if (comparison < 0) { + lower = middle + 1; + } else if (comparison > 0) { + upper = middle; + } else { + uint8_t raw_name[kOuiNameSize] = {}; + if (!reader_->read(record_offset + kOuiPrefixKeySize, raw_name, + sizeof(raw_name))) { + return OuiLookupStatus::kReadError; + } + if (!copyValidName(raw_name, result.vendor)) { + return OuiLookupStatus::kInvalidRecord; + } + found = true; + return OuiLookupStatus::kSuccess; + } + } + + return OuiLookupStatus::kSuccess; +} + +} // namespace marauder diff --git a/esp32_marauder/MarauderOui.h b/esp32_marauder/MarauderOui.h new file mode 100644 index 000000000..0a998d503 --- /dev/null +++ b/esp32_marauder/MarauderOui.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include + +namespace marauder { + +constexpr size_t kOuiMacAddressSize = 6; +constexpr size_t kOuiHeaderSize = 24; +constexpr size_t kOuiPrefixKeySize = 5; +constexpr size_t kOuiNameSize = 24; +constexpr size_t kOuiRecordSize = kOuiPrefixKeySize + kOuiNameSize; +static_assert(kOuiRecordSize == 29, "Unexpected OUI record size"); + +// Read-only view of one complete MROUI001 database. Implementations retain +// ownership of their storage and must remain valid while OuiDatabase is open. +class OuiByteReader { + public: + virtual ~OuiByteReader() = default; + + virtual size_t size() const = 0; + virtual bool read(size_t offset, uint8_t* destination, + size_t length) const = 0; +}; + +enum class OuiOpenStatus : uint8_t { + kReady, + kInvalidArgument, + kReadError, + kInvalidFormat, +}; + +enum class OuiClassification : uint8_t { + kInvalid, + kBroadcast, + kMulticast, + kLocal, + kVendor, + kUnknown, +}; + +enum class OuiLookupStatus : uint8_t { + kSuccess, + kDatabaseNotOpen, + kReadError, + kInvalidRecord, +}; + +struct MacIdentity { + OuiClassification classification; + uint8_t prefix_length; + // An owned, NUL-terminated copy. It never points into reader storage. + char vendor[kOuiNameSize]; +}; + +using OuiLookupResult = MacIdentity; + +OuiClassification classifyOuiMac( + const uint8_t mac[kOuiMacAddressSize]); + +MacIdentity identifyMacAddress( + const uint8_t mac[kOuiMacAddressSize], + const OuiByteReader* reader); + +// Parses the header once and supports repeated lookups against the same reader. +// Records are binary-searched in longest-prefix order: /36, /28, then /24. +class OuiDatabase { + public: + OuiDatabase(); + + OuiOpenStatus open(const OuiByteReader* reader); + void close(); + bool isOpen() const; + + OuiLookupStatus lookup(const uint8_t mac[kOuiMacAddressSize], + OuiLookupResult& result) const; + + private: + struct Section { + size_t offset; + uint32_t count; + uint8_t prefix_length; + }; + + OuiLookupStatus findInSection(const Section& section, + const uint8_t key[kOuiPrefixKeySize], + OuiLookupResult& result, + bool& found) const; + + const OuiByteReader* reader_; + Section sections_[3]; +}; + +} // namespace marauder diff --git a/esp32_marauder/MarauderOuiSd.cpp b/esp32_marauder/MarauderOuiSd.cpp new file mode 100644 index 000000000..d43ac1dcc --- /dev/null +++ b/esp32_marauder/MarauderOuiSd.cpp @@ -0,0 +1,141 @@ +#include "MarauderOuiSd.h" + +#ifdef HAS_OUI_LABELS + +#ifndef HAS_SD + #error "HAS_OUI_LABELS requires HAS_SD" +#endif + +#include + +#include "SD.h" +#include "SDInterface.h" + +extern SDInterface sd_obj; + +namespace marauder { +namespace { + +StoredMacIdentity makeUnresolvedIdentity(const uint8_t* mac, + OuiStorageStatus status) { + StoredMacIdentity result = {}; + result.identity = identifyMacAddress(mac, nullptr); + result.storage_status = status; + return result; +} + +} // namespace + +SdOuiDatabase::SdOuiDatabase() + : database_file_(), + database_(), + status_(OuiStorageStatus::kUnavailable) {} + +SdOuiDatabase::~SdOuiDatabase() { close(); } + +OuiStorageStatus SdOuiDatabase::open() { + close(); + if (!sd_obj.supported) { + return status_; + } + if (!SD.exists(kOuiDatabasePath)) { + File root = SD.open("/", FILE_READ); + if (!root) { + status_ = OuiStorageStatus::kInvalid; + } else { + root.close(); + } + return status_; + } + + database_file_ = SD.open(kOuiDatabasePath, FILE_READ); + if (!database_file_) { + status_ = OuiStorageStatus::kInvalid; + return status_; + } + + if (database_.open(this) != OuiOpenStatus::kReady) { + database_file_.close(); + status_ = OuiStorageStatus::kInvalid; + return status_; + } + + status_ = OuiStorageStatus::kReady; + return status_; +} + +void SdOuiDatabase::close() { + database_.close(); + if (database_file_) { + database_file_.close(); + } + status_ = OuiStorageStatus::kUnavailable; +} + +OuiStorageStatus SdOuiDatabase::status() const { return status_; } + +StoredMacIdentity SdOuiDatabase::identify( + const uint8_t mac[kOuiMacAddressSize]) const { + StoredMacIdentity result = makeUnresolvedIdentity(mac, status_); + if (result.identity.classification != OuiClassification::kUnknown || + status_ != OuiStorageStatus::kReady) { + return result; + } + + if (database_.lookup(mac, result.identity) != OuiLookupStatus::kSuccess) { + result = makeUnresolvedIdentity(mac, OuiStorageStatus::kInvalid); + } + return result; +} + +size_t SdOuiDatabase::size() const { return database_file_.size(); } + +bool SdOuiDatabase::read(size_t offset, uint8_t* destination, + size_t length) const { + if (destination == nullptr || offset > UINT32_MAX || + !database_file_.seek(static_cast(offset))) { + return false; + } + return database_file_.read(destination, length) == length; +} + +StoredMacIdentity identifyMacAddressFromSd( + const uint8_t mac[kOuiMacAddressSize]) { + StoredMacIdentity result = + makeUnresolvedIdentity(mac, OuiStorageStatus::kUnavailable); + if (result.identity.classification != OuiClassification::kUnknown) { + return result; + } + + SdOuiDatabase database; + database.open(); + return database.identify(mac); +} + +const char* ouiIdentityLabel(const StoredMacIdentity& result) { + switch (result.identity.classification) { + case OuiClassification::kVendor: + return result.identity.vendor; + case OuiClassification::kLocal: + return "local/private"; + case OuiClassification::kMulticast: + return "multicast"; + case OuiClassification::kBroadcast: + return "broadcast"; + case OuiClassification::kInvalid: + return "invalid"; + case OuiClassification::kUnknown: + if (result.storage_status == OuiStorageStatus::kUnavailable) { + return "no database"; + } + if (result.storage_status == OuiStorageStatus::kInvalid) { + return "database error"; + } + return "unknown"; + } + return "unknown"; +} + +} // namespace marauder + +#endif // HAS_OUI_LABELS diff --git a/esp32_marauder/MarauderOuiSd.h b/esp32_marauder/MarauderOuiSd.h new file mode 100644 index 000000000..d004a9feb --- /dev/null +++ b/esp32_marauder/MarauderOuiSd.h @@ -0,0 +1,56 @@ +#pragma once + +#include "configs.h" + +#ifdef HAS_OUI_LABELS + +#include "MarauderOui.h" +#include "SD.h" + +namespace marauder { + +constexpr char kOuiDatabasePath[] = "/marauder_oui.bin"; + +enum class OuiStorageStatus : uint8_t { + kReady, + kUnavailable, + kInvalid, +}; + +struct StoredMacIdentity { + MacIdentity identity; + OuiStorageStatus storage_status; +}; + +class SdOuiDatabase : private OuiByteReader { + public: + SdOuiDatabase(); + ~SdOuiDatabase(); + + SdOuiDatabase(const SdOuiDatabase&) = delete; + SdOuiDatabase& operator=(const SdOuiDatabase&) = delete; + + OuiStorageStatus open(); + void close(); + OuiStorageStatus status() const; + StoredMacIdentity identify( + const uint8_t mac[kOuiMacAddressSize]) const; + + private: + size_t size() const override; + bool read(size_t offset, uint8_t* destination, + size_t length) const override; + + mutable File database_file_; + OuiDatabase database_; + OuiStorageStatus status_; +}; + +StoredMacIdentity identifyMacAddressFromSd( + const uint8_t mac[kOuiMacAddressSize]); + +const char* ouiIdentityLabel(const StoredMacIdentity& result); + +} // namespace marauder + +#endif // HAS_OUI_LABELS diff --git a/esp32_marauder/MenuFunctions.cpp b/esp32_marauder/MenuFunctions.cpp index 8fc337da1..033d0cff6 100644 --- a/esp32_marauder/MenuFunctions.cpp +++ b/esp32_marauder/MenuFunctions.cpp @@ -1,9 +1,44 @@ -#include "MenuFunctions.h" -#include "lang_var.h" - -#ifdef HAS_SCREEN - -extern const unsigned char menu_icons[][66]; +#include "MenuFunctions.h" +#include "lang_var.h" + +#ifdef HAS_OUI_LABELS + #include "MarauderOuiSd.h" +#endif + +#ifdef HAS_SCREEN + +extern const unsigned char menu_icons[][66]; + +#ifdef HAS_OUI_LABELS +namespace { + +String formatStationOuiMenuLabel( + const uint8_t mac[marauder::kOuiMacAddressSize], + const marauder::SdOuiDatabase& database) { + const String mac_text = macToString(mac); + const marauder::StoredMacIdentity result = database.identify(mac); + + if (result.identity.classification == + marauder::OuiClassification::kVendor) { + String vendor = result.identity.vendor; + if (vendor.length() > 12) { + vendor.remove(12); + } + String compact_mac = mac_text; + compact_mac.replace(":", ""); + return compact_mac + " " + vendor; + } + if (result.identity.classification == + marauder::OuiClassification::kLocal) { + String compact_mac = mac_text; + compact_mac.replace(":", ""); + return compact_mac + " local"; + } + return mac_text; +} + +} // namespace +#endif #ifdef HAS_MINI_SCREEN void MenuFunctions::drawMiniMenuButton(int b, int x, bool selected) { @@ -2413,11 +2448,21 @@ void MenuFunctions::RunSetup() }); - // Add the AP's stations to the specific AP menu - for (int x = 0; x < access_points->get(i).stations->size(); x++) { - int cur_ap_sta = access_points->get(i).stations->get(x); - - this->addNodes(&wifiStationMenu, macToString(stations->get(cur_ap_sta)).c_str(), TFTCYAN, 255, [this, i, cur_ap_sta, x](){ + // Add the AP's stations to the specific AP menu + #ifdef HAS_OUI_LABELS + marauder::SdOuiDatabase oui_database; + oui_database.open(); + #endif + for (int x = 0; x < access_points->get(i).stations->size(); x++) { + int cur_ap_sta = access_points->get(i).stations->get(x); + + #ifdef HAS_OUI_LABELS + String station_label = formatStationOuiMenuLabel( + stations->get(cur_ap_sta).mac, oui_database); + #else + String station_label = macToString(stations->get(cur_ap_sta)); + #endif + this->addNodes(&wifiStationMenu, station_label.c_str(), TFTCYAN, 255, [this, i, cur_ap_sta, x](){ Station new_sta = stations->get(cur_ap_sta); new_sta.selected = !stations->get(cur_ap_sta).selected; @@ -2992,10 +3037,20 @@ void MenuFunctions::RunSetup() this->changeMenu(wifiAPMenu.parentMenu, true); }); - // Populate the menu with buttons - for (int i = 0; i < stations->size(); i++) { - // This is the menu node - this->addNodes(&wifiAPMenu, macToString(stations->get(i).mac).c_str(), TFTMAGENTA, 255, [this, i](){ + // Populate the menu with buttons + #ifdef HAS_OUI_LABELS + marauder::SdOuiDatabase oui_database; + oui_database.open(); + #endif + for (int i = 0; i < stations->size(); i++) { + // This is the menu node + #ifdef HAS_OUI_LABELS + String station_label = formatStationOuiMenuLabel( + stations->get(i).mac, oui_database); + #else + String station_label = macToString(stations->get(i).mac); + #endif + this->addNodes(&wifiAPMenu, station_label.c_str(), TFTMAGENTA, 255, [this, i](){ this->changeMenu(&genAPMacMenu, true); wifi_scan_obj.RunSetMac(stations->get(i).mac, false); }); diff --git a/esp32_marauder/WiFiScan.cpp b/esp32_marauder/WiFiScan.cpp index 3ac337b5f..897232b38 100644 --- a/esp32_marauder/WiFiScan.cpp +++ b/esp32_marauder/WiFiScan.cpp @@ -2,6 +2,10 @@ #include "WiFiScan.h" #include "lang_var.h" +#ifdef HAS_OUI_LABELS + #include "MarauderOuiSd.h" +#endif + #ifdef HAS_PSRAM struct mac_addr* mac_history = nullptr; #endif @@ -4198,8 +4202,17 @@ void WiFiScan::RunAPInfo(uint16_t index, bool do_display) { AccessPoint access_point = access_points->get(index); + #ifdef HAS_OUI_LABELS + const marauder::StoredMacIdentity oui_identity = + marauder::identifyMacAddressFromSd(access_point.bssid); + #endif + Serial.println(" ESSID: " + (String)access_point.essid); Serial.println(" BSSID: " + (String)macToString(access_point.bssid)); + #ifdef HAS_OUI_LABELS + Serial.print(F(" OUI: ")); + Serial.println(marauder::ouiIdentityLabel(oui_identity)); + #endif Serial.println(" Channel: " + (String)access_point.channel); Serial.println(" RSSI: " + (String)access_point.rssi); Serial.println(" Frames: " + (String)access_point.packets); @@ -4232,6 +4245,10 @@ void WiFiScan::RunAPInfo(uint16_t index, bool do_display) { if (do_display) { display_obj.tft.println(" ESSID: " + (String)access_point.essid); display_obj.tft.println(" BSSID: " + (String)macToString(access_point.bssid)); + #ifdef HAS_OUI_LABELS + display_obj.tft.print(F(" OUI: ")); + display_obj.tft.println(marauder::ouiIdentityLabel(oui_identity)); + #endif display_obj.tft.println(" Channel: " + (String)access_point.channel); display_obj.tft.println(" RSSI: " + (String)access_point.rssi); display_obj.tft.println(" Frames: " + (String)access_point.packets); diff --git a/esp32_marauder/configs.h b/esp32_marauder/configs.h index 434fa1a5d..4944dbbe4 100644 --- a/esp32_marauder/configs.h +++ b/esp32_marauder/configs.h @@ -550,6 +550,7 @@ //#define HAS_PWR_MGMT #define HAS_SCREEN #define HAS_FULL_SCREEN + #define HAS_OUI_LABELS #define HAS_GPS #define HAS_C5_SD #define HAS_SD diff --git a/platformio.ini b/platformio.ini index 73ef32d23..f01c1070f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -11,6 +11,7 @@ extra_scripts = build_src_filter = -<*> + + + build_flags = -std=gnu++17 -Wall diff --git a/test/test_oui_lookup/test_main.cpp b/test/test_oui_lookup/test_main.cpp new file mode 100644 index 000000000..9e3c5596f --- /dev/null +++ b/test/test_oui_lookup/test_main.cpp @@ -0,0 +1,350 @@ +#include + +#include +#include +#include +#include + +#include "MarauderOui.h" + +namespace { + +using marauder::OuiByteReader; +using marauder::OuiClassification; +using marauder::OuiDatabase; +using marauder::OuiLookupResult; +using marauder::OuiLookupStatus; +using marauder::OuiOpenStatus; + +constexpr size_t kNoReadFailure = static_cast(-1); + +class MemoryReader : public OuiByteReader { + public: + explicit MemoryReader(std::vector bytes) + : bytes_(std::move(bytes)), fail_at_(kNoReadFailure), reads_(0) {} + + size_t size() const override { return bytes_.size(); } + + bool read(size_t offset, uint8_t* destination, + size_t length) const override { + ++reads_; + if (destination == nullptr || offset > bytes_.size() || + length > bytes_.size() - offset) { + return false; + } + if (fail_at_ != kNoReadFailure && offset <= fail_at_ && + length > fail_at_ - offset) { + return false; + } + std::memcpy(destination, bytes_.data() + offset, length); + return true; + } + + void failAt(size_t offset) { fail_at_ = offset; } + size_t reads() const { return reads_; } + void resetReads() const { reads_ = 0; } + + private: + std::vector bytes_; + size_t fail_at_; + mutable size_t reads_; +}; + +struct Record { + std::array key; + const char* name; +}; + +void appendLittleEndian(std::vector& output, uint32_t value) { + output.push_back(static_cast(value)); + output.push_back(static_cast(value >> 8)); + output.push_back(static_cast(value >> 16)); + output.push_back(static_cast(value >> 24)); +} + +void appendRecord(std::vector& output, const Record& record) { + output.insert(output.end(), record.key.begin(), record.key.end()); + std::array name = {}; + const size_t length = std::strlen(record.name); + TEST_ASSERT_LESS_THAN(marauder::kOuiNameSize, length); + std::memcpy(name.data(), record.name, length); + output.insert(output.end(), name.begin(), name.end()); +} + +std::vector makeDatabase(const std::vector& records24, + const std::vector& records28, + const std::vector& records36) { + std::vector output = {'M', 'R', 'O', 'U', 'I', '0', '0', '1', + 1, 29, 24, 0}; + appendLittleEndian(output, static_cast(records24.size())); + appendLittleEndian(output, static_cast(records28.size())); + appendLittleEndian(output, static_cast(records36.size())); + for (const Record& record : records24) { + appendRecord(output, record); + } + for (const Record& record : records28) { + appendRecord(output, record); + } + for (const Record& record : records36) { + appendRecord(output, record); + } + return output; +} + +MemoryReader makeFullDatabase() { + return MemoryReader(makeDatabase( + {{{0x00, 0x11, 0x22, 0x00, 0x00}, "Vendor 24"}, + {{0x10, 0x20, 0x30, 0x00, 0x00}, "Second 24"}}, + {{{0x00, 0x11, 0x22, 0x30, 0x00}, "Vendor 28"}, + {{0x10, 0x20, 0x30, 0x40, 0x00}, "Second 28"}}, + {{{0x00, 0x11, 0x22, 0x33, 0x40}, "Vendor 36"}, + {{0x10, 0x20, 0x30, 0x40, 0x50}, "Second 36"}})); +} + +void assertClassification(const uint8_t* mac, + OuiClassification expected) { + MemoryReader reader = makeFullDatabase(); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kReady), + static_cast(database.open(&reader))); + reader.resetReads(); + + OuiLookupResult result = {}; + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(mac, result))); + TEST_ASSERT_EQUAL_INT(static_cast(expected), + static_cast(result.classification)); + TEST_ASSERT_EQUAL_UINT8(0, result.prefix_length); + TEST_ASSERT_EQUAL_STRING("", result.vendor); + TEST_ASSERT_EQUAL_UINT32(0, reader.reads()); +} + +} // namespace + +void setUp() {} + +void tearDown() {} + +void test_special_mac_classes_do_not_read_database() { + const uint8_t invalid[6] = {}; + const uint8_t broadcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + const uint8_t multicast[6] = {0x01, 0x00, 0x5E, 0x01, 0x02, 0x03}; + const uint8_t local[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55}; + + assertClassification(nullptr, OuiClassification::kInvalid); + assertClassification(invalid, OuiClassification::kInvalid); + assertClassification(broadcast, OuiClassification::kBroadcast); + assertClassification(multicast, OuiClassification::kMulticast); + assertClassification(local, OuiClassification::kLocal); +} + +void test_lookup_prefers_36_then_28_then_24_bit_prefixes() { + MemoryReader reader = makeFullDatabase(); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kReady), + static_cast(database.open(&reader))); + + const uint8_t mac36[6] = {0x00, 0x11, 0x22, 0x33, 0x4F, 0xAA}; + const uint8_t mac28[6] = {0x00, 0x11, 0x22, 0x3F, 0xA0, 0x01}; + const uint8_t mac24[6] = {0x00, 0x11, 0x22, 0xA0, 0x00, 0x01}; + OuiLookupResult result = {}; + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(mac36, result))); + TEST_ASSERT_EQUAL_INT(static_cast(OuiClassification::kVendor), + static_cast(result.classification)); + TEST_ASSERT_EQUAL_UINT8(36, result.prefix_length); + TEST_ASSERT_EQUAL_STRING("Vendor 36", result.vendor); + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(mac28, result))); + TEST_ASSERT_EQUAL_UINT8(28, result.prefix_length); + TEST_ASSERT_EQUAL_STRING("Vendor 28", result.vendor); + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(mac24, result))); + TEST_ASSERT_EQUAL_UINT8(24, result.prefix_length); + TEST_ASSERT_EQUAL_STRING("Vendor 24", result.vendor); +} + +void test_lookup_uses_big_endian_sorted_keys_and_section_boundaries() { + MemoryReader reader = makeFullDatabase(); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kReady), + static_cast(database.open(&reader))); + + const uint8_t first[6] = {0x00, 0x11, 0x22, 0x33, 0x4A, 0x01}; + const uint8_t last[6] = {0x10, 0x20, 0x30, 0x40, 0x5F, 0x01}; + OuiLookupResult result = {}; + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(first, result))); + TEST_ASSERT_EQUAL_STRING("Vendor 36", result.vendor); + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(last, result))); + TEST_ASSERT_EQUAL_UINT8(36, result.prefix_length); + TEST_ASSERT_EQUAL_STRING("Second 36", result.vendor); +} + +void test_global_mac_without_match_is_unknown() { + MemoryReader reader = makeFullDatabase(); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kReady), + static_cast(database.open(&reader))); + const uint8_t mac[6] = {0x00, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}; + OuiLookupResult result = {}; + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(mac, result))); + TEST_ASSERT_EQUAL_INT(static_cast(OuiClassification::kUnknown), + static_cast(result.classification)); + TEST_ASSERT_EQUAL_UINT8(0, result.prefix_length); + TEST_ASSERT_EQUAL_STRING("", result.vendor); +} + +void test_identify_mac_address_returns_owned_name_and_tolerates_null_reader() { + MemoryReader reader = makeFullDatabase(); + const uint8_t mac[6] = {0x00, 0x11, 0x22, 0x33, 0x4F, 0xAA}; + + marauder::MacIdentity identity = + marauder::identifyMacAddress(mac, &reader); + TEST_ASSERT_EQUAL_INT(static_cast(OuiClassification::kVendor), + static_cast(identity.classification)); + TEST_ASSERT_EQUAL_UINT8(36, identity.prefix_length); + TEST_ASSERT_EQUAL_STRING("Vendor 36", identity.vendor); + + reader = MemoryReader(makeDatabase({}, {}, {})); + TEST_ASSERT_EQUAL_STRING("Vendor 36", identity.vendor); + + identity = marauder::identifyMacAddress(mac, nullptr); + TEST_ASSERT_EQUAL_INT(static_cast(OuiClassification::kUnknown), + static_cast(identity.classification)); + TEST_ASSERT_EQUAL_UINT8(0, identity.prefix_length); + TEST_ASSERT_EQUAL_STRING("", identity.vendor); +} + +void test_open_validates_exact_header_and_file_size() { + const std::vector valid = makeDatabase({}, {}, {}); + + for (size_t index = 0; index < 12; ++index) { + std::vector changed = valid; + ++changed[index]; + MemoryReader reader(std::move(changed)); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kInvalidFormat), + static_cast(database.open(&reader))); + TEST_ASSERT_FALSE(database.isOpen()); + } + + std::vector trailing = valid; + trailing.push_back(0); + MemoryReader trailing_reader(std::move(trailing)); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kInvalidFormat), + static_cast(database.open(&trailing_reader))); + + std::vector truncated(valid.begin(), valid.end() - 1); + MemoryReader truncated_reader(std::move(truncated)); + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kInvalidFormat), + static_cast(database.open(&truncated_reader))); +} + +void test_open_rejects_null_reader_and_header_read_failure() { + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kInvalidArgument), + static_cast(database.open(nullptr))); + + MemoryReader reader(makeDatabase({}, {}, {})); + reader.failAt(0); + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kReadError), + static_cast(database.open(&reader))); +} + +void test_lookup_requires_open_database_only_for_global_addresses() { + OuiDatabase database; + OuiLookupResult result = {}; + const uint8_t global[6] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55}; + const uint8_t local[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55}; + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kDatabaseNotOpen), + static_cast(database.lookup(global, result))); + TEST_ASSERT_EQUAL_INT(static_cast(OuiClassification::kUnknown), + static_cast(result.classification)); + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kSuccess), + static_cast(database.lookup(local, result))); + TEST_ASSERT_EQUAL_INT(static_cast(OuiClassification::kLocal), + static_cast(result.classification)); +} + +void test_lookup_reports_reader_failure_without_stale_vendor() { + MemoryReader reader = makeFullDatabase(); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kReady), + static_cast(database.open(&reader))); + reader.failAt(marauder::kOuiHeaderSize + 5 * marauder::kOuiRecordSize); + const uint8_t mac[6] = {0x00, 0x11, 0x22, 0x33, 0x4F, 0xAA}; + OuiLookupResult result = {OuiClassification::kVendor, 36, "stale"}; + + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kReadError), + static_cast(database.lookup(mac, result))); + TEST_ASSERT_EQUAL_INT(static_cast(OuiClassification::kUnknown), + static_cast(result.classification)); + TEST_ASSERT_EQUAL_UINT8(0, result.prefix_length); + TEST_ASSERT_EQUAL_STRING("", result.vendor); +} + +void test_lookup_rejects_nonterminated_or_nonascii_vendor_name() { + std::vector nonterminated = makeDatabase( + {{{0x00, 0x11, 0x22, 0x00, 0x00}, "valid"}}, {}, {}); + std::memset(nonterminated.data() + marauder::kOuiHeaderSize + + marauder::kOuiPrefixKeySize, + 'A', marauder::kOuiNameSize); + MemoryReader nonterminated_reader(std::move(nonterminated)); + OuiDatabase database; + TEST_ASSERT_EQUAL_INT( + static_cast(OuiOpenStatus::kReady), + static_cast(database.open(&nonterminated_reader))); + const uint8_t mac[6] = {0x00, 0x11, 0x22, 0xAA, 0xBB, 0xCC}; + OuiLookupResult result = {}; + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kInvalidRecord), + static_cast(database.lookup(mac, result))); + + std::vector nonascii = makeDatabase( + {{{0x00, 0x11, 0x22, 0x00, 0x00}, "valid"}}, {}, {}); + nonascii[marauder::kOuiHeaderSize + marauder::kOuiPrefixKeySize] = 0x80; + MemoryReader nonascii_reader(std::move(nonascii)); + TEST_ASSERT_EQUAL_INT(static_cast(OuiOpenStatus::kReady), + static_cast(database.open(&nonascii_reader))); + TEST_ASSERT_EQUAL_INT( + static_cast(OuiLookupStatus::kInvalidRecord), + static_cast(database.lookup(mac, result))); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_special_mac_classes_do_not_read_database); + RUN_TEST(test_lookup_prefers_36_then_28_then_24_bit_prefixes); + RUN_TEST(test_lookup_uses_big_endian_sorted_keys_and_section_boundaries); + RUN_TEST(test_global_mac_without_match_is_unknown); + RUN_TEST( + test_identify_mac_address_returns_owned_name_and_tolerates_null_reader); + RUN_TEST(test_open_validates_exact_header_and_file_size); + RUN_TEST(test_open_rejects_null_reader_and_header_read_failure); + RUN_TEST(test_lookup_requires_open_database_only_for_global_addresses); + RUN_TEST(test_lookup_reports_reader_failure_without_stale_vendor); + RUN_TEST(test_lookup_rejects_nonterminated_or_nonascii_vendor_name); + return UNITY_END(); +} diff --git a/tools/OUI_DATABASE.md b/tools/OUI_DATABASE.md new file mode 100644 index 000000000..20f283241 --- /dev/null +++ b/tools/OUI_DATABASE.md @@ -0,0 +1,140 @@ +# Offline OUI database builder + +`build_oui_database.py` converts the four IEEE Registration Authority CSV +registries into a deterministic binary database. Builds with `HAS_OUI_LABELS` +read that database from the SD card only while rendering AP or station details; +the scan, capture and saved-log formats remain unchanged. + +The generated database is named `marauder_oui.bin` by default and is ignored by +Git. Do not commit downloaded IEEE CSV files or generated databases to this +repository. + +## Data provenance and licensing + +IEEE Registration Authority registry data is maintained and published by IEEE. +It is not covered or relicensed by ESP32 Marauder's MIT license. Before +downloading, redistributing, publishing, or bundling IEEE data or a derived +database, review and comply with the current IEEE terms, copyright notices, and +attribution requirements. + +The repository does not vendor an IEEE snapshot. A reproducible release process +should retain, outside this repository: + +- the four source URLs or local source filenames; +- the retrieval date; +- the original CSV SHA-256 hashes; +- the generated database SHA-256 printed by the converter; and +- the IEEE terms and notices that applied to that snapshot. + +The converter's `--download` mode uses these official HTTPS endpoints: + +| Registry | Prefix | Endpoint | +| --- | ---: | --- | +| MA-L | /24 | `https://standards-oui.ieee.org/oui/oui.csv` | +| MA-M | /28 | `https://standards-oui.ieee.org/oui28/mam.csv` | +| MA-S | /36 | `https://standards-oui.ieee.org/oui36/oui36.csv` | +| IAB | /36 | `https://standards-oui.ieee.org/iab/iab.csv` | + +Network access never occurs implicitly. It requires the explicit `--download` +option. + +## Usage + +Build from four already downloaded local CSV files: + +```sh +python tools/build_oui_database.py \ + --ma-l path/to/oui.csv \ + --ma-m path/to/mam.csv \ + --ma-s path/to/oui36.csv \ + --iab path/to/iab.csv +``` + +Explicitly download all four current CSV files and convert them in memory: + +```sh +python tools/build_oui_database.py --download +``` + +Select another output path with `--output`: + +```sh +python tools/build_oui_database.py --download --output build/marauder_oui.bin +``` + +Copy or upload the generated file to the SD-card root with this exact path: + +```text +/marauder_oui.bin +``` + +A reboot is not required. The next AP-detail or station menu render opens the +new file. When the file is absent, station menus keep showing the original MAC +address and AP details report `OUI: no database`. A malformed or incompatible +file is rejected and reported as `OUI: database error`. + +Supplying only some local registries, mixing local paths with `--download`, or +an invalid assignment is a hard error. Identical duplicate assignments are +emitted once. + +The public registries can contain the same prefix with different organization +names. The default behavior is conservative: every such prefix is omitted +completely instead of choosing one name arbitrarily. The converter prints a +bounded list of warnings, the total conflict count, and includes +`conflicts=` in its summary. Use `--strict-conflicts` when a pipeline +should abort if even one conflict exists: + +```sh +python tools/build_oui_database.py --download --strict-conflicts +``` + +Run the synthetic, network-free tests with: + +```sh +python -m unittest tools.test_build_oui_database +``` + +## Binary format, version 1 + +All integer counts in the header are little-endian. Prefix bytes in records are +big-endian. The file contains one 24-byte header followed by fixed 29-byte +records. + +### Header + +| Offset | Size | Field | +| ---: | ---: | --- | +| 0 | 8 | Magic ASCII `MROUI001` | +| 8 | 1 | Format version, currently `1` | +| 9 | 1 | Record size, currently `29` | +| 10 | 1 | Name field size, currently `24` | +| 11 | 1 | Reserved, must be zero | +| 12 | 4 | Number of /24 records, little-endian | +| 16 | 4 | Number of /28 records, little-endian | +| 20 | 4 | Number of /36 records, little-endian | + +Records are grouped in that same order: all /24 records, then /28 records, then +/36 records. Each group is sorted numerically by prefix. MA-S and IAB share the +/36 group and are deduplicated together. Conflicting prefixes omitted by the +converter are not included in these counts. A consumer performing +longest-prefix matching must check /36 before /28 before /24. + +### Record + +| Offset | Size | Field | +| ---: | ---: | --- | +| 0 | 5 | Prefix, big-endian and left-aligned in 40 bits | +| 5 | 24 | Printable ASCII organization name, NUL-terminated and zero-padded | + +Examples of prefix encoding: + +| Assignment | Width | Stored bytes | +| --- | ---: | --- | +| `AABBCC` | /24 | `AA BB CC 00 00` | +| `AABBCCD` | /28 | `AA BB CC D0 00` | +| `AABBCCDDE` | /36 | `AA BB CC DD E0` | + +Organization names are normalized deterministically with Unicode NFKD. +Combining marks are removed, whitespace is collapsed, remaining non-ASCII +characters become `?`, and the printable ASCII result is truncated to 23 bytes. +At least one NUL byte is therefore always present in the 24-byte name field. diff --git a/tools/build_oui_database.py b/tools/build_oui_database.py new file mode 100644 index 000000000..a89318a58 --- /dev/null +++ b/tools/build_oui_database.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 +"""Build a deterministic offline IEEE assignment database for Marauder. + +The converter accepts the four IEEE CSV registries from local files. Network +access is disabled by default and is only used when ``--download`` is passed. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import io +import os +import struct +import sys +import tempfile +import unicodedata +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Mapping, Sequence + + +MAGIC = b"MROUI001" +FORMAT_VERSION = 1 +HEADER_SIZE = 24 +PREFIX_SIZE = 5 +NAME_SIZE = 24 +RECORD_SIZE = PREFIX_SIZE + NAME_SIZE +MAX_NAME_BYTES = NAME_SIZE - 1 +MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024 +MAX_CONFLICT_WARNINGS = 20 +DEFAULT_OUTPUT = Path("marauder_oui.bin") + +# 8-byte magic, four one-byte format fields, then /24, /28 and /36 counts. +# The three counts are little-endian. Prefixes in records are big-endian. +HEADER_STRUCT = struct.Struct("<8sBBBBIII") + + +class OuiDatabaseError(RuntimeError): + """Raised when source data cannot be converted without ambiguity.""" + + +@dataclass(frozen=True) +class RegistrySpec: + key: str + registry_name: str + prefix_bits: int + url: str + + +REGISTRY_SPECS: tuple[RegistrySpec, ...] = ( + RegistrySpec( + key="ma_l", + registry_name="MA-L", + prefix_bits=24, + url="https://standards-oui.ieee.org/oui/oui.csv", + ), + RegistrySpec( + key="ma_m", + registry_name="MA-M", + prefix_bits=28, + url="https://standards-oui.ieee.org/oui28/mam.csv", + ), + RegistrySpec( + key="ma_s", + registry_name="MA-S", + prefix_bits=36, + url="https://standards-oui.ieee.org/oui36/oui36.csv", + ), + RegistrySpec( + key="iab", + registry_name="IAB", + prefix_bits=36, + url="https://standards-oui.ieee.org/iab/iab.csv", + ), +) +REGISTRY_BY_KEY = {spec.key: spec for spec in REGISTRY_SPECS} + + +@dataclass(frozen=True) +class CsvSource: + spec: RegistrySpec + label: str + payload: bytes + + +@dataclass(frozen=True, order=True) +class OuiRecord: + prefix_bits: int + prefix_value: int + organization_name: str + + +@dataclass(frozen=True) +class OuiConflict: + prefix_bits: int + prefix_value: int + # Each choice is (normalized organization name, sorted source labels). + choices: tuple[tuple[str, tuple[str, ...]], ...] + + @property + def assignment(self) -> str: + return f"{self.prefix_value:0{self.prefix_bits // 4}X}" + + def describe(self) -> str: + organizations = "; ".join( + f"{name!r} ({', '.join(labels)})" for name, labels in self.choices + ) + return f"/{self.prefix_bits} {self.assignment}: {organizations}" + + +@dataclass(frozen=True) +class BuildSummary: + output: Path + count_24: int + count_28: int + count_36: int + conflicts: tuple[OuiConflict, ...] + sha256: str + + @property + def record_count(self) -> int: + return self.count_24 + self.count_28 + self.count_36 + + @property + def conflict_count(self) -> int: + return len(self.conflicts) + + +def normalize_organization_name(value: str) -> str: + """Return deterministic printable ASCII without applying truncation.""" + + normalized = unicodedata.normalize("NFKD", value) + output: list[str] = [] + pending_space = False + + for character in normalized: + if unicodedata.combining(character): + continue + if character.isspace(): + pending_space = bool(output) + continue + if pending_space: + output.append(" ") + pending_space = False + codepoint = ord(character) + if 0x21 <= codepoint <= 0x7E: + output.append(character) + elif codepoint == 0x20: + output.append(" ") + else: + output.append("?") + + result = "".join(output).strip() + if not result: + raise OuiDatabaseError("Organization name is empty after ASCII normalization.") + return result + + +def encode_organization_name(value: str) -> bytes: + """Encode a normalized name as 23 printable bytes plus NUL padding.""" + + normalized = normalize_organization_name(value) + truncated = normalized[:MAX_NAME_BYTES].rstrip() + if not truncated: + raise OuiDatabaseError("Organization name is empty after truncation.") + encoded = truncated.encode("ascii", errors="strict") + if any(byte < 0x20 or byte > 0x7E for byte in encoded): + raise OuiDatabaseError("Organization name contains non-printable ASCII.") + return encoded + bytes(NAME_SIZE - len(encoded)) + + +def canonical_field_name(value: str | None) -> str: + if value is None: + return "" + return "".join(character.lower() for character in value if character.isalnum()) + + +def resolve_csv_fields(fieldnames: Sequence[str | None] | None, label: str) -> dict[str, str]: + if not fieldnames: + raise OuiDatabaseError(f"{label}: CSV header is missing.") + + canonical = {canonical_field_name(name): name for name in fieldnames if name is not None} + required = { + "assignment": "Assignment", + "organizationname": "Organization Name", + } + missing = [display for key, display in required.items() if key not in canonical] + if missing: + raise OuiDatabaseError(f"{label}: missing CSV column(s): {', '.join(missing)}.") + + fields = { + "assignment": canonical["assignment"], + "organization": canonical["organizationname"], + } + if "registry" in canonical: + fields["registry"] = canonical["registry"] + return fields + + +def parse_assignment(value: str, spec: RegistrySpec, label: str, row_number: int) -> int: + assignment = value.strip() + expected_digits = spec.prefix_bits // 4 + if len(assignment) != expected_digits: + raise OuiDatabaseError( + f"{label}:{row_number}: {spec.registry_name} assignment must contain " + f"exactly {expected_digits} hexadecimal digits." + ) + try: + parsed = int(assignment, 16) + except ValueError as error: + raise OuiDatabaseError( + f"{label}:{row_number}: assignment is not hexadecimal: {assignment!r}." + ) from error + if parsed >= (1 << spec.prefix_bits): + raise OuiDatabaseError(f"{label}:{row_number}: assignment exceeds prefix width.") + return parsed + + +def parse_csv_source(source: CsvSource) -> list[OuiRecord]: + try: + text = source.payload.decode("utf-8-sig") + except UnicodeDecodeError as error: + raise OuiDatabaseError(f"{source.label}: CSV is not valid UTF-8.") from error + + reader = csv.DictReader(io.StringIO(text, newline="")) + fields = resolve_csv_fields(reader.fieldnames, source.label) + records: list[OuiRecord] = [] + + for row_number, row in enumerate(reader, start=2): + if row is None: + continue + if any(isinstance(value, list) for value in row.values()): + raise OuiDatabaseError( + f"{source.label}:{row_number}: CSV row has more values than header columns." + ) + if not any((value or "").strip() for value in row.values()): + continue + + if "registry" in fields: + registry = (row.get(fields["registry"]) or "").strip() + if registry and registry.casefold() != source.spec.registry_name.casefold(): + raise OuiDatabaseError( + f"{source.label}:{row_number}: expected registry " + f"{source.spec.registry_name}, got {registry!r}." + ) + + assignment = parse_assignment( + row.get(fields["assignment"]) or "", + source.spec, + source.label, + row_number, + ) + try: + organization = normalize_organization_name( + row.get(fields["organization"]) or "" + ) + except OuiDatabaseError as error: + raise OuiDatabaseError(f"{source.label}:{row_number}: {error}") from error + + records.append( + OuiRecord( + prefix_bits=source.spec.prefix_bits, + prefix_value=assignment, + organization_name=organization, + ) + ) + + if not records: + raise OuiDatabaseError(f"{source.label}: CSV contains no assignment records.") + return records + + +def format_conflicts(conflicts: Sequence[OuiConflict], limit: int) -> str: + displayed = conflicts[:limit] + details = "\n".join(f" - {conflict.describe()}" for conflict in displayed) + remaining = len(conflicts) - len(displayed) + if remaining: + details += f"\n - ... {remaining} additional conflict(s) omitted" + return details + + +def merge_records( + sources: Iterable[CsvSource], *, strict_conflicts: bool = False +) -> tuple[list[OuiRecord], tuple[OuiConflict, ...]]: + candidates: dict[tuple[int, int], dict[str, set[str]]] = {} + + for source in sources: + for record in parse_csv_source(source): + key = (record.prefix_bits, record.prefix_value) + organizations = candidates.setdefault(key, {}) + organizations.setdefault(record.organization_name, set()).add(source.label) + + records: list[OuiRecord] = [] + conflicts: list[OuiConflict] = [] + for (prefix_bits, prefix_value), organizations in candidates.items(): + if len(organizations) == 1: + organization_name = next(iter(organizations)) + records.append( + OuiRecord( + prefix_bits=prefix_bits, + prefix_value=prefix_value, + organization_name=organization_name, + ) + ) + continue + choices = tuple( + (name, tuple(sorted(labels))) + for name, labels in sorted(organizations.items()) + ) + conflicts.append( + OuiConflict( + prefix_bits=prefix_bits, + prefix_value=prefix_value, + choices=choices, + ) + ) + + records.sort(key=lambda record: (record.prefix_bits, record.prefix_value)) + conflicts.sort(key=lambda conflict: (conflict.prefix_bits, conflict.prefix_value)) + frozen_conflicts = tuple(conflicts) + if strict_conflicts and frozen_conflicts: + raise OuiDatabaseError( + f"Found {len(frozen_conflicts)} conflicting assignment(s):\n" + f"{format_conflicts(frozen_conflicts, MAX_CONFLICT_WARNINGS)}" + ) + if not records: + raise OuiDatabaseError("No OUI records were produced.") + return records, frozen_conflicts + + +def encode_prefix(record: OuiRecord) -> bytes: + if record.prefix_bits not in (24, 28, 36): + raise OuiDatabaseError(f"Unsupported prefix width: /{record.prefix_bits}.") + shifted = record.prefix_value << (PREFIX_SIZE * 8 - record.prefix_bits) + return shifted.to_bytes(PREFIX_SIZE, byteorder="big", signed=False) + + +def build_database_blob( + sources: Iterable[CsvSource], *, strict_conflicts: bool = False +) -> tuple[bytes, tuple[int, int, int], tuple[OuiConflict, ...]]: + records, conflicts = merge_records(sources, strict_conflicts=strict_conflicts) + count_24 = sum(record.prefix_bits == 24 for record in records) + count_28 = sum(record.prefix_bits == 28 for record in records) + count_36 = sum(record.prefix_bits == 36 for record in records) + + header = HEADER_STRUCT.pack( + MAGIC, + FORMAT_VERSION, + RECORD_SIZE, + NAME_SIZE, + 0, + count_24, + count_28, + count_36, + ) + if len(header) != HEADER_SIZE: + raise AssertionError("Internal header size mismatch.") + + body = bytearray() + for record in records: + body.extend(encode_prefix(record)) + body.extend(encode_organization_name(record.organization_name)) + + expected_size = HEADER_SIZE + len(records) * RECORD_SIZE + blob = header + bytes(body) + if len(blob) != expected_size: + raise AssertionError("Internal database size mismatch.") + return blob, (count_24, count_28, count_36), conflicts + + +def write_database( + sources: Iterable[CsvSource], + output: Path, + *, + strict_conflicts: bool = False, +) -> BuildSummary: + blob, counts, conflicts = build_database_blob( + sources, strict_conflicts=strict_conflicts + ) + output = output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=f".{output.name}.", + suffix=".tmp", + dir=output.parent, + delete=False, + ) as temporary: + temporary.write(blob) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + os.replace(temporary_path, output) + temporary_path = None + except OSError as error: + raise OuiDatabaseError(f"Cannot write {output}: {error}") from error + finally: + if temporary_path is not None: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + + return BuildSummary( + output=output, + count_24=counts[0], + count_28=counts[1], + count_36=counts[2], + conflicts=conflicts, + sha256=hashlib.sha256(blob).hexdigest(), + ) + + +def read_local_source(spec: RegistrySpec, path: Path) -> CsvSource: + try: + payload = path.read_bytes() + except OSError as error: + raise OuiDatabaseError(f"Cannot read {path}: {error}") from error + return CsvSource(spec=spec, label=str(path), payload=payload) + + +def download_source(spec: RegistrySpec, timeout: float) -> CsvSource: + request = urllib.request.Request( + spec.url, + headers={"User-Agent": "ESP32Marauder-OUI-Builder/1"}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = response.read(MAX_DOWNLOAD_BYTES + 1) + except OSError as error: + raise OuiDatabaseError(f"Cannot download {spec.url}: {error}") from error + if len(payload) > MAX_DOWNLOAD_BYTES: + raise OuiDatabaseError(f"Download exceeds {MAX_DOWNLOAD_BYTES} bytes: {spec.url}") + return CsvSource(spec=spec, label=spec.url, payload=payload) + + +def sources_from_paths(paths: Mapping[str, Path]) -> list[CsvSource]: + return [read_local_source(spec, paths[spec.key]) for spec in REGISTRY_SPECS] + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Build Marauder's deterministic offline IEEE OUI database." + ) + parser.add_argument( + "--download", + action="store_true", + help="explicitly download all four current CSVs from IEEE over HTTPS", + ) + parser.add_argument("--ma-l", type=Path, help="local IEEE MA-L (/24) CSV") + parser.add_argument("--ma-m", type=Path, help="local IEEE MA-M (/28) CSV") + parser.add_argument("--ma-s", type=Path, help="local IEEE MA-S (/36) CSV") + parser.add_argument("--iab", type=Path, help="local IEEE IAB (/36) CSV") + parser.add_argument( + "-o", + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"output path (default: {DEFAULT_OUTPUT})", + ) + parser.add_argument( + "--timeout", + type=float, + default=30.0, + help="per-download timeout in seconds (default: 30)", + ) + parser.add_argument( + "--strict-conflicts", + action="store_true", + help="abort instead of omitting prefixes assigned to conflicting names", + ) + return parser + + +def validate_arguments( + parser: argparse.ArgumentParser, args: argparse.Namespace +) -> Mapping[str, Path] | None: + local_values = { + "ma_l": args.ma_l, + "ma_m": args.ma_m, + "ma_s": args.ma_s, + "iab": args.iab, + } + supplied = {key: value for key, value in local_values.items() if value is not None} + + if args.download and supplied: + parser.error("--download cannot be combined with local CSV paths") + if not args.download and len(supplied) != len(REGISTRY_SPECS): + parser.error("provide all four local CSV paths or use explicit --download") + if args.timeout <= 0: + parser.error("--timeout must be greater than zero") + if args.download: + return None + paths = {key: value for key, value in local_values.items() if value is not None} + output = args.output.resolve() + if any(path.resolve() == output for path in paths.values()): + parser.error("--output must not overwrite an input CSV") + return paths + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_argument_parser() + args = parser.parse_args(argv) + local_paths = validate_arguments(parser, args) + + try: + if args.download: + sources = [download_source(spec, args.timeout) for spec in REGISTRY_SPECS] + else: + if local_paths is None: + raise AssertionError("Validated local paths are missing.") + sources = sources_from_paths(local_paths) + summary = write_database( + sources, + args.output, + strict_conflicts=args.strict_conflicts, + ) + except OuiDatabaseError as error: + parser.exit(1, f"error: {error}\n") + + if summary.conflicts: + for conflict in summary.conflicts[:MAX_CONFLICT_WARNINGS]: + print( + f"warning: omitted conflicting assignment {conflict.describe()}", + file=sys.stderr, + ) + remaining = summary.conflict_count - min( + summary.conflict_count, MAX_CONFLICT_WARNINGS + ) + if remaining: + print( + f"warning: {remaining} additional conflict(s) omitted; " + f"total conflicts={summary.conflict_count}", + file=sys.stderr, + ) + + print( + f"Wrote {summary.record_count} records " + f"(/24={summary.count_24}, /28={summary.count_28}, /36={summary.count_36}) " + f"with conflicts={summary.conflict_count} to {summary.output}" + ) + print(f"SHA-256: {summary.sha256}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_build_oui_database.py b/tools/test_build_oui_database.py new file mode 100644 index 000000000..f31796d04 --- /dev/null +++ b/tools/test_build_oui_database.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import contextlib +import io +import struct +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from tools.build_oui_database import ( + FORMAT_VERSION, + HEADER_SIZE, + HEADER_STRUCT, + MAGIC, + NAME_SIZE, + RECORD_SIZE, + REGISTRY_BY_KEY, + CsvSource, + OuiDatabaseError, + build_database_blob, + encode_organization_name, + main, + normalize_organization_name, +) + + +def csv_payload(registry: str, rows: list[tuple[str, str]]) -> bytes: + lines = ["Registry,Assignment,Organization Name,Organization Address"] + lines.extend( + f'{registry},{assignment},"{organization}",Test Address' + for assignment, organization in rows + ) + return ("\n".join(lines) + "\n").encode("utf-8") + + +def source(key: str, rows: list[tuple[str, str]]) -> CsvSource: + spec = REGISTRY_BY_KEY[key] + return CsvSource( + spec=spec, + label=f"synthetic-{key}.csv", + payload=csv_payload(spec.registry_name, rows), + ) + + +def decode_records(blob: bytes) -> list[tuple[bytes, bytes]]: + records: list[tuple[bytes, bytes]] = [] + for offset in range(HEADER_SIZE, len(blob), RECORD_SIZE): + record = blob[offset : offset + RECORD_SIZE] + records.append((record[:5], record[5:])) + return records + + +class OuiDatabaseBuilderTests(unittest.TestCase): + def test_builds_documented_header_groups_and_left_aligned_prefixes(self) -> None: + sources = [ + source( + "ma_l", + [ + ("AABBCC", "Zeta Devices"), + ("001122", "Café Devices"), + ("001122", "Café Devices"), + ], + ), + source("ma_m", [("0011223", "Middle Devices")]), + source("ma_s", [("001122334", "Small Devices")]), + source("iab", [("001122335", "Legacy Devices")]), + ] + + blob, counts, conflicts = build_database_blob(sources) + + self.assertEqual(counts, (2, 1, 2)) + self.assertEqual(conflicts, ()) + self.assertEqual(len(blob), HEADER_SIZE + 5 * RECORD_SIZE) + self.assertEqual( + HEADER_STRUCT.unpack_from(blob), + (MAGIC, FORMAT_VERSION, RECORD_SIZE, NAME_SIZE, 0, 2, 1, 2), + ) + + records = decode_records(blob) + self.assertEqual( + [prefix for prefix, _name in records], + [ + bytes.fromhex("0011220000"), + bytes.fromhex("AABBCC0000"), + bytes.fromhex("0011223000"), + bytes.fromhex("0011223340"), + bytes.fromhex("0011223350"), + ], + ) + first_name = records[0][1].split(b"\0", 1)[0] + self.assertEqual(first_name, b"Cafe Devices") + + def test_output_is_deterministic_for_reordered_sources_and_rows(self) -> None: + forward = [ + source("ma_l", [("001122", "Alpha"), ("AABBCC", "Beta")]), + source("ma_m", [("0011223", "Gamma")]), + source("ma_s", [("001122334", "Delta")]), + source("iab", [("001122335", "Epsilon")]), + ] + reverse = [ + source("iab", [("001122335", "Epsilon")]), + source("ma_s", [("001122334", "Delta")]), + source("ma_m", [("0011223", "Gamma")]), + source("ma_l", [("AABBCC", "Beta"), ("001122", "Alpha")]), + ] + + self.assertEqual(build_database_blob(forward)[0], build_database_blob(reverse)[0]) + + def test_identical_ma_s_and_iab_assignment_is_deduplicated(self) -> None: + sources = [ + source("ma_l", [("001122", "Alpha")]), + source("ma_m", [("0011223", "Beta")]), + source("ma_s", [("001122334", "Same Organization")]), + source("iab", [("001122334", "Same Organization")]), + ] + + blob, counts, conflicts = build_database_blob(sources) + + self.assertEqual(counts, (1, 1, 1)) + self.assertEqual(conflicts, ()) + self.assertEqual(len(blob), HEADER_SIZE + 3 * RECORD_SIZE) + + def test_conflicting_duplicate_assignment_is_omitted_completely(self) -> None: + sources = [ + source("ma_l", [("001122", "Alpha")]), + source("ma_m", [("0011223", "Beta")]), + source("ma_s", [("001122334", "First Organization")]), + source("iab", [("001122334", "Second Organization")]), + ] + + blob, counts, conflicts = build_database_blob(sources) + + self.assertEqual(counts, (1, 1, 0)) + self.assertEqual(len(blob), HEADER_SIZE + 2 * RECORD_SIZE) + self.assertEqual(len(conflicts), 1) + self.assertEqual(conflicts[0].prefix_bits, 36) + self.assertEqual(conflicts[0].assignment, "001122334") + self.assertNotIn(bytes.fromhex("0011223340"), blob[HEADER_SIZE:]) + + def test_strict_conflicts_aborts_without_selecting_an_organization(self) -> None: + sources = [ + source("ma_l", [("001122", "Alpha")]), + source("ma_m", [("0011223", "Beta")]), + source("ma_s", [("001122334", "First Organization")]), + source("iab", [("001122334", "Second Organization")]), + ] + + with self.assertRaisesRegex(OuiDatabaseError, "Found 1 conflicting assignment"): + build_database_blob(sources, strict_conflicts=True) + + def test_rejects_bad_assignment_width_hex_and_registry(self) -> None: + valid_tail = [ + source("ma_m", [("0011223", "Beta")]), + source("ma_s", [("001122334", "Gamma")]), + source("iab", [("001122335", "Delta")]), + ] + + with self.assertRaisesRegex(OuiDatabaseError, "exactly 6 hexadecimal digits"): + build_database_blob([source("ma_l", [("1122", "Alpha")]), *valid_tail]) + with self.assertRaisesRegex(OuiDatabaseError, "not hexadecimal"): + build_database_blob([source("ma_l", [("00GG22", "Alpha")]), *valid_tail]) + + wrong_registry = CsvSource( + spec=REGISTRY_BY_KEY["ma_l"], + label="wrong-registry.csv", + payload=csv_payload("MA-M", [("001122", "Alpha")]), + ) + with self.assertRaisesRegex(OuiDatabaseError, "expected registry MA-L"): + build_database_blob([wrong_registry, *valid_tail]) + + def test_name_normalization_is_printable_nul_terminated_and_bounded(self) -> None: + normalized = normalize_organization_name( + " Müller\t设备 Incorporated With A Very Long Name " + ) + self.assertEqual( + normalized, + "Muller ?? Incorporated With A Very Long Name", + ) + + encoded = encode_organization_name(normalized) + + self.assertEqual(len(encoded), NAME_SIZE) + self.assertIn(0, encoded) + name = encoded.split(b"\0", 1)[0] + self.assertLessEqual(len(name), NAME_SIZE - 1) + self.assertTrue(all(0x20 <= byte <= 0x7E for byte in name)) + self.assertEqual(encoded[-1], 0) + + def test_cli_requires_all_local_files_and_never_downloads_implicitly(self) -> None: + with mock.patch( + "tools.build_oui_database.urllib.request.urlopen" + ) as urlopen: + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as context: + main(["--ma-l", "only-one.csv"]) + self.assertEqual(context.exception.code, 2) + urlopen.assert_not_called() + + def test_cli_writes_database_from_four_local_csvs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths: dict[str, Path] = {} + assignments = { + "ma_l": "001122", + "ma_m": "0011223", + "ma_s": "001122334", + "iab": "001122335", + } + for key, assignment in assignments.items(): + spec = REGISTRY_BY_KEY[key] + path = root / f"{key}.csv" + path.write_bytes(csv_payload(spec.registry_name, [(assignment, key)])) + paths[key] = path + output = root / "custom.bin" + + with contextlib.redirect_stdout(io.StringIO()): + result = main( + [ + "--ma-l", + str(paths["ma_l"]), + "--ma-m", + str(paths["ma_m"]), + "--ma-s", + str(paths["ma_s"]), + "--iab", + str(paths["iab"]), + "--output", + str(output), + ] + ) + + self.assertEqual(result, 0) + blob = output.read_bytes() + self.assertEqual(blob[:8], MAGIC) + self.assertEqual(struct.unpack_from(" None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths: dict[str, Path] = {} + assignments = { + "ma_l": "001122", + "ma_m": "0011223", + "ma_s": "001122334", + "iab": "001122335", + } + for key, assignment in assignments.items(): + spec = REGISTRY_BY_KEY[key] + path = root / f"{key}.csv" + path.write_bytes(csv_payload(spec.registry_name, [(assignment, key)])) + paths[key] = path + + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as context: + main( + [ + "--ma-l", + str(paths["ma_l"]), + "--ma-m", + str(paths["ma_m"]), + "--ma-s", + str(paths["ma_s"]), + "--iab", + str(paths["iab"]), + "--output", + str(paths["ma_l"]), + ] + ) + + self.assertEqual(context.exception.code, 2) + + def test_cli_warns_reports_and_omits_conflicts(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + rows = { + "ma_l": [("001122", "Alpha")], + "ma_m": [("0011223", "Beta")], + "ma_s": [("001122334", "First Organization")], + "iab": [("001122334", "Second Organization")], + } + paths: dict[str, Path] = {} + for key, registry_rows in rows.items(): + spec = REGISTRY_BY_KEY[key] + path = root / f"{key}.csv" + path.write_bytes(csv_payload(spec.registry_name, registry_rows)) + paths[key] = path + output = root / "with-conflict.bin" + arguments = [ + "--ma-l", + str(paths["ma_l"]), + "--ma-m", + str(paths["ma_m"]), + "--ma-s", + str(paths["ma_s"]), + "--iab", + str(paths["iab"]), + "--output", + str(output), + ] + stdout = io.StringIO() + stderr = io.StringIO() + + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + result = main(arguments) + + self.assertEqual(result, 0) + self.assertIn("conflicts=1", stdout.getvalue()) + self.assertIn("omitted conflicting assignment /36 001122334", stderr.getvalue()) + self.assertEqual(struct.unpack_from(" Date: Sun, 9 Aug 2026 21:39:45 +0200 Subject: [PATCH 2/2] fix: invalidate OUI database after lookup errors --- esp32_marauder/MarauderOuiSd.cpp | 13 +++++++++++-- esp32_marauder/MarauderOuiSd.h | 3 ++- esp32_marauder/MenuFunctions.cpp | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/esp32_marauder/MarauderOuiSd.cpp b/esp32_marauder/MarauderOuiSd.cpp index d43ac1dcc..5909811df 100644 --- a/esp32_marauder/MarauderOuiSd.cpp +++ b/esp32_marauder/MarauderOuiSd.cpp @@ -75,7 +75,7 @@ void SdOuiDatabase::close() { OuiStorageStatus SdOuiDatabase::status() const { return status_; } StoredMacIdentity SdOuiDatabase::identify( - const uint8_t mac[kOuiMacAddressSize]) const { + const uint8_t mac[kOuiMacAddressSize]) { StoredMacIdentity result = makeUnresolvedIdentity(mac, status_); if (result.identity.classification != OuiClassification::kUnknown || status_ != OuiStorageStatus::kReady) { @@ -83,11 +83,20 @@ StoredMacIdentity SdOuiDatabase::identify( } if (database_.lookup(mac, result.identity) != OuiLookupStatus::kSuccess) { - result = makeUnresolvedIdentity(mac, OuiStorageStatus::kInvalid); + invalidate(); + result = makeUnresolvedIdentity(mac, status_); } return result; } +void SdOuiDatabase::invalidate() { + database_.close(); + if (database_file_) { + database_file_.close(); + } + status_ = OuiStorageStatus::kInvalid; +} + size_t SdOuiDatabase::size() const { return database_file_.size(); } bool SdOuiDatabase::read(size_t offset, uint8_t* destination, diff --git a/esp32_marauder/MarauderOuiSd.h b/esp32_marauder/MarauderOuiSd.h index d004a9feb..c7ebd83f1 100644 --- a/esp32_marauder/MarauderOuiSd.h +++ b/esp32_marauder/MarauderOuiSd.h @@ -34,9 +34,10 @@ class SdOuiDatabase : private OuiByteReader { void close(); OuiStorageStatus status() const; StoredMacIdentity identify( - const uint8_t mac[kOuiMacAddressSize]) const; + const uint8_t mac[kOuiMacAddressSize]); private: + void invalidate(); size_t size() const override; bool read(size_t offset, uint8_t* destination, size_t length) const override; diff --git a/esp32_marauder/MenuFunctions.cpp b/esp32_marauder/MenuFunctions.cpp index 033d0cff6..7b5f62716 100644 --- a/esp32_marauder/MenuFunctions.cpp +++ b/esp32_marauder/MenuFunctions.cpp @@ -14,7 +14,7 @@ namespace { String formatStationOuiMenuLabel( const uint8_t mac[marauder::kOuiMacAddressSize], - const marauder::SdOuiDatabase& database) { + marauder::SdOuiDatabase& database) { const String mac_text = macToString(mac); const marauder::StoredMacIdentity result = database.identify(mac);