diff --git a/.github/workflows/githubci.yml b/.github/workflows/githubci.yml index 25b79fa..a755ca3 100644 --- a/.github/workflows/githubci.yml +++ b/.github/workflows/githubci.yml @@ -3,6 +3,26 @@ name: Arduino Library CI on: [pull_request, push, repository_dispatch] jobs: + nmea-tests: + name: NMEA regression tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Run host tests with sanitizers + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/nmea-tests" + for test in nmea_validate nmea_fields nmea_utilities; do + echo "Running $test" + g++ -std=c++11 -Wall -Wextra -Werror -g \ + -fsanitize=address,undefined -fno-sanitize-recover=all \ + -fno-omit-frame-pointer -Isrc src/Adafruit_NMEA.cpp \ + "extras/tests/$test/$test.cpp" -o "$RUNNER_TEMP/nmea-tests/$test" + "$RUNNER_TEMP/nmea-tests/$test" + done + build: runs-on: ubuntu-latest diff --git a/extras/tests/nmea_utilities/nmea_utilities.cpp b/extras/tests/nmea_utilities/nmea_utilities.cpp new file mode 100644 index 0000000..9648c08 --- /dev/null +++ b/extras/tests/nmea_utilities/nmea_utilities.cpp @@ -0,0 +1,167 @@ +/* Host regression for decimal parsing and command construction: + g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined -Isrc \ + src/Adafruit_NMEA.cpp extras/tests/nmea_utilities/nmea_utilities.cpp \ + -o /tmp/nmea_utilities + /tmp/nmea_utilities +*/ + +#include "Adafruit_NMEA.h" +#include +#include +#include +#include + +static void expectNumber(nmea_span_t field, nmea_number_status_t status, + int64_t coefficient = 0, uint8_t decimalPlaces = 0); +static void expectCommand(const char *body, const char *expected); +static void expectRejected(const char *body, size_t length); + +int main() { + struct { + const char *text; + int64_t coefficient; + uint8_t decimalPlaces; + } numbers[] = {{"0", 0, 0}, + {"-0.000", 0, 3}, + {"-123.4500", -1234500, 4}, + {"+12", 12, 0}, + {"-.5", -5, 1}, + {"1.", 1, 0}, + {"00012.0300", 120300, 4}, + {"4807.038123456", INT64_C(4807038123456), 9}, + {"9223372036854775807", INT64_MAX, 0}, + {"-9223372036854775808", INT64_MIN, 0}, + {"922337203685477580.7", INT64_MAX, 1}, + {"-922337203685477580.8", INT64_MIN, 1}}; + for (const auto &number : numbers) + expectNumber({number.text, strlen(number.text)}, NMEA_NUMBER_VALID, + number.coefficient, number.decimalPlaces); + puts("PASS: exact decimals, retained zeros, and signed 64-bit limits"); + + const char *badNumbers[] = { + ".", "+", "-", "+.", "--1", "1.2.3", " 1", + "1 ", "1e2", "NaN", "inf", "1,2", "1*00", "9223372036854775808x"}; + for (const char *text : badNumbers) + expectNumber({text, strlen(text)}, NMEA_NUMBER_BAD_FORMAT); + const char *overflow[] = {"9223372036854775808", "-9223372036854775809", + "99999999999999999999999999"}; + for (const char *text : overflow) + expectNumber({text, strlen(text)}, NMEA_NUMBER_OUT_OF_RANGE); + expectNumber({NULL, 100}, NMEA_NUMBER_MISSING); + expectNumber({"1", 0}, NMEA_NUMBER_EMPTY); + const char embeddedNul[] = {'1', 0, '2'}; + expectNumber({embeddedNul, sizeof(embeddedNul)}, NMEA_NUMBER_BAD_FORMAT); + const char highByte[] = {'1', (char)0x80}; + expectNumber({highByte, sizeof(highByte)}, NMEA_NUMBER_BAD_FORMAT); + puts("PASS: missing, empty, malformed, and overflowing numbers are distinct"); + + const char bounded[] = {'-', '1', '.', '2'}; + expectNumber({bounded, sizeof(bounded)}, NMEA_NUMBER_VALID, -12, 1); + expectNumber({"12x", 2}, NMEA_NUMBER_VALID, 12, 0); + char fraction[258]; + memset(fraction, '0', sizeof(fraction)); + fraction[1] = '.'; + expectNumber({fraction, 257}, NMEA_NUMBER_VALID, 0, 255); + expectNumber({fraction, sizeof(fraction)}, NMEA_NUMBER_OUT_OF_RANGE); + char zeros[300]; + memset(zeros, '0', sizeof(zeros)); + expectNumber({zeros, sizeof(zeros)}, NMEA_NUMBER_VALID); + puts("PASS: bounded numeric input and decimal-place limits"); + + expectCommand("PQTMVERNO", "$PQTMVERNO*58\r\n"); + expectCommand("PQTMVERNO,", "$PQTMVERNO,*74\r\n"); + expectCommand("PAIR002", "$PAIR002*38\r\n"); + expectCommand("PAIR001,004,0", "$PAIR001,004,0*3F\r\n"); + expectCommand("PMTK220,1000", "$PMTK220,1000*1F\r\n"); + puts("PASS: known command checksums and every output capacity boundary"); + + const char *badBodies[] = {"", ",", ",A", "A B", "$A", "!A", + "A*41", "A\r", "A\n", "A,\t", "A,\x7f", "A,\x80"}; + for (const char *body : badBodies) + expectRejected(body, strlen(body)); + expectRejected(NULL, 10); + expectRejected("A", SIZE_MAX); + expectRejected(embeddedNul, sizeof(embeddedNul)); + assert(Adafruit_NMEA::buildCommand(NULL, 100, "A", 1) == 0); + puts("PASS: malformed commands and invalid arguments produce no partial " + "output"); + + char overlap[40]; + memset(overlap, 'A', sizeof(overlap)); + assert(Adafruit_NMEA::buildCommand(overlap, 20, overlap + 4, 7) == 0); + assert(overlap[0] == 0); + for (size_t i = 1; i < sizeof(overlap); i++) + assert(overlap[i] == 'A'); + memset(overlap, 'A', sizeof(overlap)); + assert(Adafruit_NMEA::buildCommand(overlap + 4, 20, overlap, 7) == 0); + for (size_t i = 0; i < sizeof(overlap); i++) { + if (i == 4) + assert(overlap[i] == 0); + else + assert(overlap[i] == 'A'); + } + memset(overlap, 'A', sizeof(overlap)); + assert(Adafruit_NMEA::buildCommand(overlap, 20, overlap, 7) == 0); + puts("PASS: overlapping source and destination ranges are rejected"); + + char adjacent[13]; + memcpy(adjacent + 10, "A,1", 3); + assert(Adafruit_NMEA::buildCommand(adjacent, 10, adjacent + 10, 3) == 9); + assert(strcmp(adjacent, "$A,1*5C\r\n") == 0); + assert(memcmp(adjacent + 10, "A,1", 3) == 0); + memcpy(adjacent, "A,1", 3); + assert(Adafruit_NMEA::buildCommand(adjacent + 3, 10, adjacent, 3) == 9); + assert(strcmp(adjacent + 3, "$A,1*5C\r\n") == 0); + assert(memcmp(adjacent, "A,1", 3) == 0); + puts("PASS: adjacent buffers and nonterminated command bodies are supported"); + return 0; +} + +static void expectNumber(nmea_span_t field, nmea_number_status_t status, + int64_t coefficient, uint8_t decimalPlaces) { + nmea_decimal_t result = Adafruit_NMEA::parseDecimal(field); + assert(result.status == status); + assert(result.coefficient == coefficient); + assert(result.decimalPlaces == decimalPlaces); +} + +static void expectCommand(const char *body, const char *expected) { + size_t required = strlen(expected) + 1; + for (size_t capacity = 0; capacity <= required + 1; capacity++) { + size_t allocated = capacity; + if (!allocated) + allocated = 1; + char *output = (char *)malloc(allocated); + assert(output != NULL); + memset(output, '?', allocated); + size_t length = + Adafruit_NMEA::buildCommand(output, capacity, body, strlen(body)); + if (capacity < required) { + assert(length == 0); + if (capacity) + assert(output[0] == 0); + else + assert(output[0] == '?'); + for (size_t i = 1; i < allocated; i++) + assert(output[i] == '?'); + } else { + assert(length == required - 1); + assert(strcmp(output, expected) == 0); + assert(Adafruit_NMEA::validate(output, length).status == + NMEA_FRAME_VALID); + if (capacity > required) + assert(output[required] == '?'); + } + free(output); + } +} + +static void expectRejected(const char *body, size_t length) { + char output[64]; + memset(output, '?', sizeof(output)); + assert(Adafruit_NMEA::buildCommand(output, sizeof(output), body, length) == + 0); + assert(output[0] == 0); + for (size_t i = 1; i < sizeof(output); i++) + assert(output[i] == '?'); +} diff --git a/src/Adafruit_NMEA.cpp b/src/Adafruit_NMEA.cpp index aad8dd7..35c351e 100644 --- a/src/Adafruit_NMEA.cpp +++ b/src/Adafruit_NMEA.cpp @@ -1,13 +1,14 @@ /**************************************************************************/ /*! @file Adafruit_NMEA.cpp - @brief Hardware-independent NMEA sentence validation. + @brief Hardware-independent NMEA sentence and field utilities. Written for Adafruit Industries. BSD license; see license.txt. */ /**************************************************************************/ #include "Adafruit_NMEA.h" +#include /**************************************************************************/ /*! @@ -151,3 +152,166 @@ nmea_span_t Adafruit_NMEA::nextField(nmea_span_t &remaining) { } return field; } + +/**************************************************************************/ +/*! + @brief Convert a complete field to an exact signed decimal value. + @param field Borrowed readable text, with no NUL terminator required. + @return Status, integer coefficient, and decimal-place count. Both numeric + members are zero on failure. NULL data is MISSING; zero length is EMPTY. + + Accepts an optional sign, digits, and at most one decimal point. At least + one digit is required. Whitespace, exponents, and non-digit suffixes are + rejected. The supplied fractional digit count is retained, including zeros. + Coefficients must fit int64_t and decimal-place counts must fit uint8_t. + Malformed syntax takes precedence over overflow. No rounding, floating-point + conversion, allocation, or input modification occurs. +*/ +/**************************************************************************/ +nmea_decimal_t Adafruit_NMEA::parseDecimal(nmea_span_t field) { + nmea_decimal_t result = {NMEA_NUMBER_BAD_FORMAT, 0, 0}; + if (!field.data) { + result.status = NMEA_NUMBER_MISSING; + return result; + } + if (!field.length) { + result.status = NMEA_NUMBER_EMPTY; + return result; + } + + size_t start = 0; + bool negative = false; + if (field.data[0] == '-' || field.data[0] == '+') { + negative = field.data[0] == '-'; + start = 1; + } + + // Accumulate negatively: INT64_MIN has no positive int64_t counterpart. + int64_t limit = -INT64_MAX; + if (negative) + limit = INT64_MIN; + int64_t cutoff = limit / 10; + uint8_t lastDigitLimit = (uint8_t)(-(limit % 10)); + int64_t coefficient = 0; + uint8_t decimalPlaces = 0; + bool decimalPoint = false; + bool hasDigit = false; + bool overflow = false; + for (size_t i = start; i < field.length; i++) { + char c = field.data[i]; + if (c == '.' && !decimalPoint) { + decimalPoint = true; + continue; + } + if (c < '0' || c > '9') + return result; + hasDigit = true; + uint8_t digit = c - '0'; + if (decimalPoint) { + if (decimalPlaces == UINT8_MAX) + overflow = true; + else + decimalPlaces++; + } + if (!overflow) { + if (coefficient < cutoff || + (coefficient == cutoff && digit > lastDigitLimit)) + overflow = true; + else + coefficient = coefficient * 10 - digit; + } + } + if (!hasDigit) + return result; + if (overflow) { + result.status = NMEA_NUMBER_OUT_OF_RANGE; + return result; + } + + result.status = NMEA_NUMBER_VALID; + result.coefficient = coefficient; + if (!negative) + result.coefficient = -coefficient; + result.decimalPlaces = decimalPlaces; + return result; +} + +/**************************************************************************/ +/*! + @brief Build a checksummed command in caller-provided storage. + @param output Writable buffer for the complete command and NUL terminator. + @param capacity Size of output in bytes, including space for NUL. + @param body Readable address and optional comma-separated fields, without + '$', '!', '*', control characters, or non-ASCII bytes. No NUL is required. + @param bodyLength Number of bytes in body, excluding any NUL terminator. + @return Bytes written excluding NUL, or zero on invalid input, overlapping + buffers, or insufficient capacity. On failure, output[0] is cleared if + output is non-NULL and capacity is nonzero; no other output bytes are + changed. + + Produces "$*HH\r\n" followed by NUL, using uppercase checksum digits. + Requires bodyLength + NMEA_COMMAND_OVERHEAD bytes of storage. The address + must be nonempty and ASCII alphanumeric. Zero-field commands are allowed. + Arguments, capacity, and body syntax are checked before constructing output. + No heap allocation or transport I/O occurs. +*/ +/**************************************************************************/ +size_t Adafruit_NMEA::buildCommand(char *output, size_t capacity, + const char *body, size_t bodyLength) { + bool valid = output && body && bodyLength && + capacity >= NMEA_COMMAND_OVERHEAD && + bodyLength <= capacity - NMEA_COMMAND_OVERHEAD; + if (valid) { + // Compare address differences so checking overlap cannot overflow an end + // address. Reject overlap with the caller's entire writable output span. + uintptr_t outputAddress = (uintptr_t)output; + uintptr_t bodyAddress = (uintptr_t)body; + if (outputAddress <= bodyAddress) + valid = bodyAddress - outputAddress >= capacity; + else + valid = outputAddress - bodyAddress >= bodyLength; + } + + uint8_t checksum = 0; + bool inAddress = true; + if (valid) { + for (size_t i = 0; i < bodyLength; i++) { + uint8_t c = (uint8_t)body[i]; + if (c < ' ' || c > '~' || c == '$' || c == '!' || c == '*') { + valid = false; + break; + } + if (inAddress) { + if (c == ',' && i > 0) { + inAddress = false; + } else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'))) { + valid = false; + break; + } + } + checksum ^= c; + } + } + if (!valid) { + if (output && capacity) + output[0] = '\0'; + return 0; + } + + output[0] = '$'; + memcpy(output + 1, body, bodyLength); + size_t end = bodyLength + 1; + output[end++] = '*'; + uint8_t digits[] = {(uint8_t)(checksum / 16), (uint8_t)(checksum % 16)}; + for (uint8_t i = 0; i < 2; i++) { + if (digits[i] < 10) + output[end++] = '0' + digits[i]; + else + output[end++] = 'A' + digits[i] - 10; + } + output[end++] = '\r'; + output[end++] = '\n'; + output[end] = '\0'; + return end; +} diff --git a/src/Adafruit_NMEA.h b/src/Adafruit_NMEA.h index e26bd8e..dab6d40 100644 --- a/src/Adafruit_NMEA.h +++ b/src/Adafruit_NMEA.h @@ -1,7 +1,7 @@ /**************************************************************************/ /*! @file Adafruit_NMEA.h - @brief Hardware-independent NMEA sentence validation. + @brief Hardware-independent NMEA sentence and field utilities. Written for Adafruit Industries. BSD license; see license.txt. */ @@ -13,6 +13,8 @@ #include #include +#define NMEA_COMMAND_OVERHEAD 7 ///< Bytes for '$', '*HH', CR/LF, and NUL. + /** Result of framing or validating a sentence. Recognition belongs to GNSS * or receiver-specific decoders: an unknown address can still be VALID. */ typedef enum : uint8_t { @@ -43,11 +45,32 @@ typedef struct { nmea_span_t fields; ///< Text after the first comma and before '*'. } nmea_sentence_t; +/// Result of converting one complete field to a decimal number. +typedef enum : uint8_t { + NMEA_NUMBER_VALID, ///< The complete field is a representable decimal. + NMEA_NUMBER_MISSING, ///< Field is absent. + NMEA_NUMBER_EMPTY, ///< Field is present but empty. + NMEA_NUMBER_BAD_FORMAT, ///< Invalid character or decimal syntax. + NMEA_NUMBER_OUT_OF_RANGE ///< Coefficient or decimal-place count overflowed. +} nmea_number_status_t; + +/** Exact decimal value: coefficient / 10^decimalPlaces, with no float step. + * Coordinate interpretation and conversion to degrees belong to Adafruit_GNSS. + * On any conversion failure, coefficient and decimalPlaces are both zero. */ +typedef struct { + nmea_number_status_t status; ///< Result of conversion. + int64_t coefficient; ///< Signed integer containing the decimal digits. + uint8_t decimalPlaces; ///< Number of digits following the decimal point. +} nmea_decimal_t; + /// Hardware-independent NMEA utilities shared by receiver classes. class Adafruit_NMEA { public: static nmea_sentence_t validate(const char *data, size_t length); static nmea_span_t nextField(nmea_span_t &remaining); + static nmea_decimal_t parseDecimal(nmea_span_t field); + static size_t buildCommand(char *output, size_t capacity, const char *body, + size_t bodyLength); }; #endif // ADAFRUIT_NMEA_H