Merge pull request #184 from ladyada-eagleclaw/main

Add bounded NMEA sentence validation
This commit is contained in:
Eagle Claw authored and GitHub committed 2026-09-15 14:20:49 -04:00
commit 74cf488d40
4 files changed
+416

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
/**************************************************************************/
/*!
@file Adafruit_NMEA.h
@brief Proposed hardware-independent NMEA API for review.
Declarations only: this proposal is not implemented or installed from src/.
Private storage and class layout will be defined with the implementation.
Existing Adafruit_GPS code does not use this header.
Intended inheritance:
Adafruit_NMEA -> Adafruit_GNSS -> Adafruit_GPS (existing MTK API)
-> Adafruit_LC29H (Quectel API)
Written for Adafruit Industries. BSD license; see license.txt.
*/
/**************************************************************************/
#ifndef ADAFRUIT_NMEA_H
#define ADAFRUIT_NMEA_H
#include <stddef.h>
#include <stdint.h>
/** 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 {
NMEA_FRAME_INCOMPLETE, ///< More bytes are needed; no new sentence is ready.
NMEA_FRAME_VALID, ///< Complete format and checksum are valid.
NMEA_FRAME_BAD_FORMAT, ///< Invalid argument, address, framing, or trailer.
NMEA_FRAME_BAD_CHECKSUM, ///< Two hex checksum digits do not match the
///< payload.
NMEA_FRAME_OVERFLOW ///< Receive buffer has no room for the whole sentence.
} nmea_frame_status_t;
/** Borrowed text with an explicit length; not necessarily NUL-terminated.
* NULL data means absent. Non-NULL data with length zero means present/empty.
* The owner must keep the underlying storage alive and unchanged while used.
*/
typedef struct {
const char *data; ///< First character, or NULL for an absent field.
size_t length; ///< Number of characters, excluding any NUL terminator.
} nmea_span_t;
/** Sentence views. Only text is available when a complete sentence is invalid;
* address and fields require VALID status. No text is copied or whitelisted.
*/
typedef struct {
nmea_frame_status_t status; ///< Result of validation.
nmea_span_t text; ///< Entire supplied sentence, including its trailer.
nmea_span_t address; ///< Full address, e.g. GNGGA, PAIR001, or PQTMVERNO.
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;
/** Proposed framing and field utilities shared by receiver classes.
*
* No transport, GPS fix data, command waits, heap allocation, or field arrays.
* Framing state and timestamps belong to each instance. Supplied-buffer
* validation, field iteration, conversion, and command building are stateless.
* One caller feeds each instance; concurrent access requires synchronization.
*/
class Adafruit_NMEA {
public:
// Borrow two non-overlapping buffers, each capacity bytes including NUL.
// They must outlive this object. This permits reuse of GPS's existing pair.
// Invalid storage makes feed() return BAD_FORMAT without writing to it.
Adafruit_NMEA(char *firstBuffer, char *secondBuffer, size_t capacity);
// Copies must not share writable receive buffers with independent state.
Adafruit_NMEA(const Adafruit_NMEA &) = delete;
Adafruit_NMEA &operator=(const Adafruit_NMEA &) = delete;
// Drop receive state and invalidate all views returned by lastSentence().
void reset();
// Supply one byte and its receive time in milliseconds (e.g. millis()).
// Ignore bytes before '$' or '!'; a new start character restarts assembly.
// LF completes the sentence; its format and checksum determine the result.
// Overflow discards that sentence and resumes at the next start character.
// Each complete, non-overflowed line replaces lastSentence(), even if
// invalid. Retaining invalid raw text permits the existing lastNMEA()/logger
// behavior.
nmea_frame_status_t feed(uint8_t byte, uint32_t receivedAtMs);
// Return the latest complete line, or INCOMPLETE/absent spans before one.
// Views expire on the next complete line, reset(), or destruction.
// Process each line promptly: there is no queue of older sentences.
nmea_sentence_t lastSentence() const;
// Time of the '$' or '!' of the latest complete sentence; zero before one
// exists. Zero is also a valid timestamp; use lastSentence().status to tell
// them apart.
uint32_t sentenceStartedAt() const;
// Time of the LF of that same sentence; unsigned times wrap like millis().
uint32_t sentenceReceivedAt() const;
// Validate exactly length bytes, excluding NUL; never modify or overread
// data. Accept '$' or '!', a nonempty ASCII alphanumeric address, optional
// fields,
// '*', two hex checksum digits, then optional CR followed by optional LF.
// Reject embedded NUL/control characters and extra trailer characters.
// Missing/invalid hex digits are BAD_FORMAT; a mismatch is BAD_CHECKSUM.
// Zero fields means absent fields, e.g. "$PQTMVERNO*58".
// One empty field means non-NULL fields.data with zero length.
// NULL data yields BAD_FORMAT with all spans absent.
// Returned spans borrow data; receive state and timestamps are unchanged.
static nmea_sentence_t validate(const char *data, size_t length);
// Start with a copy of a VALID sentence's fields span. Return the next field
// and advance remaining in place. Preserve empty and trailing empty fields.
// After the last field, remaining and subsequent results are absent.
// This supports one forward scan without a stored array of field offsets.
static nmea_span_t nextField(nmea_span_t &remaining);
// Convert a whole field: optional '+'/'-', then digits and at most one '.'.
// At least one digit is required; whitespace, exponents, and NaN are invalid.
// Retain the supplied fractional digit count; never round or silently clamp.
static nmea_decimal_t parseDecimal(nmea_span_t field);
// Build "$<body>*HH\r\n" plus NUL into caller-provided storage.
// body contains an address and optional fields, without '$', '!', '*', NUL,
// or control characters. Source/output must not overlap.
// capacity includes NUL; return bytes excluding NUL, or zero on error.
// On error, clear output[0] if output is non-NULL and capacity is nonzero.
// Check arguments and capacity before writing; no heap allocation or I/O.
static size_t buildCommand(char *output, size_t capacity, const char *body,
size_t bodyLength);
};
#endif // ADAFRUIT_NMEA_H
@@ -0,0 +1,105 @@
/* Host regression for the hardware-independent core. From the repository root:
g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined -Isrc \
src/Adafruit_NMEA.cpp extras/tests/nmea_validate/nmea_validate.cpp \
-o /tmp/nmea_validate
/tmp/nmea_validate
*/
#include "Adafruit_NMEA.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void expect(const char *data, size_t length, nmea_frame_status_t status);
static void expectText(nmea_span_t span, const char *text);
int main() {
const char *valid[] = {
"$A*41", "$A*41\r", "$A*41\n", "$A*41\r\n",
"!A*41", "$PQTMVERNO*58", "$PAIR001,004,0*3f", "$A, *4D"};
for (const char *text : valid)
expect(text, strlen(text), NMEA_FRAME_VALID);
puts("PASS: checksums, line endings, unknown and proprietary addresses");
nmea_sentence_t sentence =
Adafruit_NMEA::validate("$PQTMVERNO*58", sizeof("$PQTMVERNO*58") - 1);
expectText(sentence.address, "PQTMVERNO");
assert(sentence.fields.data == NULL && sentence.fields.length == 0);
sentence =
Adafruit_NMEA::validate("$PQTMVERNO,*74", sizeof("$PQTMVERNO,*74") - 1);
assert(sentence.status == NMEA_FRAME_VALID);
assert(sentence.fields.data != NULL && sentence.fields.length == 0);
sentence = Adafruit_NMEA::validate("$A,,*41", 7);
assert(sentence.status == NMEA_FRAME_VALID);
expectText(sentence.fields, ",");
puts("PASS: absent, empty, and trailing empty fields stay distinct");
const char *malformed[] = {
"", "$", "$A", "$A*", "$A*4", "$A*4Z",
"$A*Z1", "$A*+1", "$A*411", "$A*41junk", "$A*41\n\r", "$A*41\r\r",
"$A*41\n\n", "$*00", "$,*2C", "$A B*23", "$A,*", "$A**41",
"$A,$*49", "$A,!*4C", "$A,\t*64", "A*41", "$A*41$B*42"};
for (const char *text : malformed)
expect(text, strlen(text), NMEA_FRAME_BAD_FORMAT);
expect("$PQTMVERNO*59", sizeof("$PQTMVERNO*59") - 1, NMEA_FRAME_BAD_CHECKSUM);
expect(NULL, 0, NMEA_FRAME_BAD_FORMAT);
expect(NULL, 100, NMEA_FRAME_BAD_FORMAT);
const char embeddedNul[] = {'$', 'A', ',', 0, '*', '6', 'D'};
expect(embeddedNul, sizeof(embeddedNul), NMEA_FRAME_BAD_FORMAT);
const char nonAscii[] = {'$', 'A', ',', (char)0x80, '*', 'E', 'D'};
expect(nonAscii, sizeof(nonAscii), NMEA_FRAME_BAD_FORMAT);
puts("PASS: malformed framing, checksum digits, control bytes, and NULL "
"input");
const char fix[] =
"$GPGGA,120009,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*40\r\n";
const size_t length = sizeof(fix) - 1;
// Each allocation ends at the declared length, so AddressSanitizer detects
// any lookahead past it. The complete checksum needs no optional CR/LF.
for (size_t i = 0; i <= length; i++) {
char *bounded = (char *)malloc(i ? i : 1);
assert(bounded != NULL);
memcpy(bounded, fix, i);
nmea_frame_status_t status = NMEA_FRAME_BAD_FORMAT;
if (i >= length - 2)
status = NMEA_FRAME_VALID;
expect(bounded, i, status);
assert(memcmp(bounded, fix, i) == 0);
free(bounded);
}
puts("PASS: every truncated prefix and exact-length nonterminated input");
sentence = Adafruit_NMEA::validate(fix, length);
expectText(sentence.address, "GPGGA");
expectText(sentence.fields,
"120009,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,");
nmea_sentence_t other =
Adafruit_NMEA::validate("$PQTMVERNO*58", sizeof("$PQTMVERNO*58") - 1);
expectText(other.address, "PQTMVERNO");
expectText(sentence.address, "GPGGA");
assert(sentence.text.data == fix && sentence.text.length == length);
puts("PASS: views borrow input and independent results do not overwrite it");
return 0;
}
static void expect(const char *data, size_t length,
nmea_frame_status_t status) {
nmea_sentence_t result = Adafruit_NMEA::validate(data, length);
if (result.status != status) {
fprintf(stderr, "FAIL: length %zu, expected status %d, got %d\n", length,
status, result.status);
abort();
}
assert(result.text.data == data);
assert(result.text.length == (data ? length : 0));
if (status != NMEA_FRAME_VALID) {
assert(result.address.data == NULL && result.address.length == 0);
assert(result.fields.data == NULL && result.fields.length == 0);
}
}
static void expectText(nmea_span_t span, const char *text) {
assert(span.data != NULL && span.length == strlen(text));
assert(memcmp(span.data, text, span.length) == 0);
}
+111
View File
@@ -0,0 +1,111 @@
/**************************************************************************/
/*!
@file Adafruit_NMEA.cpp
@brief Hardware-independent NMEA sentence validation.
Written for Adafruit Industries. BSD license; see license.txt.
*/
/**************************************************************************/
#include "Adafruit_NMEA.h"
/**************************************************************************/
/*!
@brief Validate a complete, length-bounded NMEA sentence without copying it.
@param data Readable input buffer; no NUL terminator is required.
@param length Number of bytes to inspect, excluding any NUL terminator.
@return Status and borrowed spans. Address and fields are absent on failure.
Raw text remains available unless data is NULL, which gives absent spans.
Accepts '$' or '!', an ASCII alphanumeric address, optional comma-separated
fields, '*', two hex checksum digits, optional CR, and optional LF, in that
order. A valid but unknown address is accepted. Embedded start markers,
control characters, non-ASCII bytes, and extra trailer bytes are rejected.
Missing or invalid hex digits are BAD_FORMAT; a mismatch is BAD_CHECKSUM.
A zero-field message such as "$PQTMVERNO*58" has absent fields. A comma
immediately followed by '*' denotes one empty field, with non-NULL data
and zero length. Returned views require the input to remain unchanged.
This function allocates no memory and has no shared or per-instance state.
*/
/**************************************************************************/
nmea_sentence_t Adafruit_NMEA::validate(const char *data, size_t length) {
nmea_sentence_t result = {
NMEA_FRAME_BAD_FORMAT, {NULL, 0}, {NULL, 0}, {NULL, 0}};
if (!data)
return result;
result.text.data = data;
result.text.length = length;
// The shortest sentence has a start, one address character, '*', and two hex
// digits. Check the length before reading even the start character.
if (length < 5 || (data[0] != '$' && data[0] != '!'))
return result;
size_t addressEnd = 0;
size_t checksumOffset = 0;
uint8_t checksum = 0;
for (size_t i = 1; i < length; i++) {
uint8_t c = (uint8_t)data[i];
if (c == '*') {
checksumOffset = i;
break;
}
if (c < ' ' || c > '~' || c == '$' || c == '!')
return result;
if (!addressEnd) {
if (c == ',') {
addressEnd = i;
} else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9'))) {
return result;
}
}
checksum ^= c;
}
if (!checksumOffset)
return result;
if (!addressEnd)
addressEnd = checksumOffset;
if (addressEnd == 1 || length - checksumOffset < 3)
return result;
// Decode both checksum digits only after proving that both are in bounds.
uint8_t expected = 0;
for (size_t i = checksumOffset + 1; i < checksumOffset + 3; i++) {
char c = data[i];
uint8_t digit;
if (c >= '0' && c <= '9')
digit = c - '0';
else if (c >= 'A' && c <= 'F')
digit = c - 'A' + 10;
else if (c >= 'a' && c <= 'f')
digit = c - 'a' + 10;
else
return result;
expected = expected * 16 + digit;
}
size_t end = checksumOffset + 3;
if (end < length && data[end] == '\r')
end++;
if (end < length && data[end] == '\n')
end++;
if (end != length)
return result;
if (checksum != expected) {
result.status = NMEA_FRAME_BAD_CHECKSUM;
return result;
}
result.status = NMEA_FRAME_VALID;
result.address.data = data + 1;
result.address.length = addressEnd - 1;
if (addressEnd < checksumOffset) {
result.fields.data = data + addressEnd + 1;
result.fields.length = checksumOffset - addressEnd - 1;
}
return result;
}
+52
View File
@@ -0,0 +1,52 @@
/**************************************************************************/
/*!
@file Adafruit_NMEA.h
@brief Hardware-independent NMEA sentence validation.
Written for Adafruit Industries. BSD license; see license.txt.
*/
/**************************************************************************/
#ifndef ADAFRUIT_NMEA_H
#define ADAFRUIT_NMEA_H
#include <stddef.h>
#include <stdint.h>
/** 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 {
NMEA_FRAME_INCOMPLETE, ///< More bytes are needed; no new sentence is ready.
NMEA_FRAME_VALID, ///< Complete format and checksum are valid.
NMEA_FRAME_BAD_FORMAT, ///< Invalid argument, address, framing, or trailer.
NMEA_FRAME_BAD_CHECKSUM, ///< Two hex checksum digits do not match the
///< payload.
NMEA_FRAME_OVERFLOW ///< Receive buffer has no room for the whole sentence.
} nmea_frame_status_t;
/** Borrowed text with an explicit length; not necessarily NUL-terminated.
* NULL data means absent. Non-NULL data with length zero means present/empty.
* The owner must keep the underlying storage alive and unchanged while used.
*/
typedef struct {
const char *data; ///< First character, or NULL for an absent field.
size_t length; ///< Number of characters, excluding any NUL terminator.
} nmea_span_t;
/** Sentence views. Only text is available when a sentence is invalid;
* address and fields require VALID status. No text is copied or whitelisted.
*/
typedef struct {
nmea_frame_status_t status; ///< Result of validation.
nmea_span_t text; ///< Entire supplied sentence, including its trailer.
nmea_span_t address; ///< Full address, e.g. GNGGA, PAIR001, or PQTMVERNO.
nmea_span_t fields; ///< Text after the first comma and before '*'.
} nmea_sentence_t;
/// Hardware-independent NMEA utilities shared by receiver classes.
class Adafruit_NMEA {
public:
static nmea_sentence_t validate(const char *data, size_t length);
};
#endif // ADAFRUIT_NMEA_H