diff --git a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp index 12cf7469..3491959d 100644 --- a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp +++ b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.cpp @@ -13,6 +13,8 @@ constexpr std::size_t MAX_LOCATION_ELEMENTS = 16; constexpr std::size_t MAX_SKIP_DEPTH = 8; constexpr std::size_t MAX_SKIP_ITEMS = 64; constexpr std::size_t MAX_ENCODED_TELEMETRY = 96; +constexpr std::size_t MAX_CUSTOM_META_ENTRIES = 16; +constexpr std::size_t MAX_ENCODED_CUSTOM_META = 128; class Cursor { public: @@ -64,6 +66,45 @@ public: return true; } + bool readBoolean(bool& value) { + uint8_t marker = 0; + if (!readByte(marker)) return false; + if (marker == 0xc2U) { + value = false; + return true; + } + if (marker == 0xc3U) { + value = true; + return true; + } + return false; + } + + bool readString(BinaryView& value) { + uint8_t marker = 0; + if (!readByte(marker)) return false; + + std::size_t length = 0; + if ((marker & 0xe0U) == 0xa0U) { + length = marker & 0x1fU; + } else if (marker == 0xd9U) { + uint8_t byte_length = 0; + if (!readByte(byte_length)) return false; + length = byte_length; + } else if (marker == 0xdaU) { + if (!readBigEndianSize(2, length)) return false; + } else if (marker == 0xdbU) { + if (!readBigEndianSize(4, length)) return false; + } else { + return false; + } + + const uint8_t* bytes = nullptr; + if (!readBytes(length, bytes)) return false; + value = BinaryView{bytes, length}; + return true; + } + bool readMapSize(std::size_t& count) { uint8_t marker = 0; if (!readByte(marker)) return false; @@ -262,6 +303,23 @@ public: return writeByte(0xcfU) && writeBigEndian(value, 8); } + bool writeBoolean(bool value) { + return writeByte(value ? 0xc3U : 0xc2U); + } + + bool writeString(const char* value, std::size_t length) { + if (value == nullptr) return false; + if (length <= 31U) { + return writeByte(static_cast(0xa0U | length)) && + writeBytes(reinterpret_cast(value), length); + } + if (length <= 0xffU) { + return writeByte(0xd9U) && writeByte(static_cast(length)) && + writeBytes(reinterpret_cast(value), length); + } + return false; + } + bool writeBinary(const uint8_t* data, std::size_t size) { if (size > 0xffU) return false; return writeByte(0xc4U) && writeByte(static_cast(size)) && @@ -355,6 +413,12 @@ bool locationInRange(const LocationTelemetry& location) { location.longitude_e6 <= 180000000; } +bool stringEquals(const BinaryView& value, const char* expected, + std::size_t expected_size) { + return value.size == expected_size && + std::memcmp(value.data, expected, expected_size) == 0; +} + } // namespace FieldValueResult unwrapLxmfBinaryFieldValue( @@ -501,4 +565,100 @@ EncodeResult encodeLocationTelemetry( return EncodeResult::OK; } +CustomMetaResult decodeCustomLocationMeta( + const uint8_t* data, + std::size_t size, + CustomLocationMeta& output) { + if (data == nullptr || size == 0) return CustomMetaResult::INVALID_ARGUMENT; + + Cursor cursor(data, size); + std::size_t map_size = 0; + if (!cursor.readMapSize(map_size) || map_size > MAX_CUSTOM_META_ENTRIES) { + return CustomMetaResult::MALFORMED; + } + + CustomLocationMeta candidate{}; + std::size_t skip_budget = MAX_SKIP_ITEMS; + for (std::size_t index = 0; index < map_size; ++index) { + BinaryView key{}; + if (!cursor.readString(key)) return CustomMetaResult::MALFORMED; + + if (stringEquals(key, "cease", 5)) { + if (candidate.has_cease || !cursor.readBoolean(candidate.cease)) { + return CustomMetaResult::MALFORMED; + } + candidate.has_cease = true; + } else if (stringEquals(key, "expires", 7)) { + if (candidate.has_expires || + !cursor.readUnsigned(candidate.expires_millis)) { + return CustomMetaResult::MALFORMED; + } + candidate.has_expires = true; + } else if (stringEquals(key, "approxRadius", 12)) { + uint64_t radius = 0; + if (candidate.has_approx_radius || !cursor.readUnsigned(radius) || + radius > std::numeric_limits::max()) { + return CustomMetaResult::MALFORMED; + } + candidate.approx_radius_meters = static_cast(radius); + candidate.has_approx_radius = true; + } else if (stringEquals(key, "ts", 2)) { + if (candidate.has_timestamp || + !cursor.readUnsigned(candidate.timestamp_millis)) { + return CustomMetaResult::MALFORMED; + } + candidate.has_timestamp = true; + } else if (!cursor.skipValue(0, skip_budget)) { + return CustomMetaResult::MALFORMED; + } + } + + if (!cursor.atEnd()) return CustomMetaResult::MALFORMED; + output = candidate; + return CustomMetaResult::OK; +} + +CustomMetaResult encodeCustomLocationMeta( + const CustomLocationMeta& input, + uint8_t* output, + std::size_t capacity, + std::size_t& written) { + const std::size_t field_count = + static_cast(input.has_cease) + + static_cast(input.has_expires) + + static_cast(input.has_approx_radius) + + static_cast(input.has_timestamp); + if (field_count == 0) { + written = 0; + return CustomMetaResult::EMPTY; + } + if (output == nullptr) return CustomMetaResult::INVALID_ARGUMENT; + + uint8_t temporary[MAX_ENCODED_CUSTOM_META]{}; + Writer writer(temporary, sizeof(temporary)); + bool ok = writer.writeByte(static_cast(0x80U | field_count)); + if (input.has_cease) { + ok = ok && writer.writeString("cease", 5) && + writer.writeBoolean(input.cease); + } + if (input.has_expires) { + ok = ok && writer.writeString("expires", 7) && + writer.writeUnsigned(input.expires_millis); + } + if (input.has_approx_radius) { + ok = ok && writer.writeString("approxRadius", 12) && + writer.writeUnsigned(input.approx_radius_meters); + } + if (input.has_timestamp) { + ok = ok && writer.writeString("ts", 2) && + writer.writeUnsigned(input.timestamp_millis); + } + + if (!ok) return CustomMetaResult::INVALID_ARGUMENT; + if (capacity < writer.size()) return CustomMetaResult::BUFFER_TOO_SMALL; + std::memcpy(output, temporary, writer.size()); + written = writer.size(); + return CustomMetaResult::OK; +} + } // namespace Telemetry diff --git a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h index f7a494c6..d06570fb 100644 --- a/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h +++ b/lib/tdeck_ui/Telemetry/LocationTelemetryCodec.h @@ -24,6 +24,17 @@ struct LocationTelemetry { uint64_t sensor_timestamp_seconds = 0; }; +struct CustomLocationMeta { + bool has_cease = false; + bool cease = false; + bool has_expires = false; + uint64_t expires_millis = 0; + bool has_approx_radius = false; + uint32_t approx_radius_meters = 0; + bool has_timestamp = false; + uint64_t timestamp_millis = 0; +}; + enum class DecodeResult : uint8_t { OK, INVALID_ARGUMENT, @@ -47,6 +58,14 @@ enum class FieldValueResult : uint8_t { BUFFER_TOO_SMALL, }; +enum class CustomMetaResult : uint8_t { + OK, + EMPTY, + INVALID_ARGUMENT, + MALFORMED, + BUFFER_TOO_SMALL, +}; + struct BinaryView { BinaryView() = default; BinaryView(const uint8_t* bytes, std::size_t length) @@ -79,6 +98,17 @@ EncodeResult encodeLocationTelemetry( std::size_t capacity, std::size_t& written); +CustomMetaResult decodeCustomLocationMeta( + const uint8_t* data, + std::size_t size, + CustomLocationMeta& output); + +CustomMetaResult encodeCustomLocationMeta( + const CustomLocationMeta& input, + uint8_t* output, + std::size_t capacity, + std::size_t& written); + } // namespace Telemetry #endif // PYXIS_TELEMETRY_LOCATION_TELEMETRY_CODEC_H diff --git a/tests/native/test_custom_location_meta_codec.cpp b/tests/native/test_custom_location_meta_codec.cpp new file mode 100644 index 00000000..5c457fa7 --- /dev/null +++ b/tests/native/test_custom_location_meta_codec.cpp @@ -0,0 +1,178 @@ +#include +#include +#include +#include +#include + +#include "Telemetry/LocationTelemetryCodec.h" + +namespace { + +int passed = 0; +int failures = 0; + +#define CHECK(expr) \ + do { \ + if (expr) { \ + ++passed; \ + } else { \ + ++failures; \ + std::cerr << "FAIL line " << __LINE__ << ": " #expr << '\n'; \ + } \ + } while (false) + +// Generated by Columba's org.msgpack.core 0.9.10 codec at Android commit +// 7946af5ab61c53fad4aaa54d0555a45eb779a040. +constexpr uint8_t CANONICAL[] = { + 0x84, + 0xa5, 'c', 'e', 'a', 's', 'e', 0xc3, + 0xa7, 'e', 'x', 'p', 'i', 'r', 'e', 's', + 0xcf, 0x00, 0x00, 0x01, 0x8b, 0xcf, 0xe5, 0x68, 0x7b, + 0xac, 'a', 'p', 'p', 'r', 'o', 'x', 'R', 'a', 'd', 'i', 'u', 's', + 0xcc, 0xfa, + 0xa2, 't', 's', + 0xcf, 0x00, 0x00, 0x01, 0x8b, 0xcf, 0xe5, 0x69, 0xc8, +}; + +Telemetry::CustomLocationMeta expectedMeta() { + Telemetry::CustomLocationMeta value{}; + value.has_cease = true; + value.cease = true; + value.has_expires = true; + value.expires_millis = 1700000000123ULL; + value.has_approx_radius = true; + value.approx_radius_meters = 250; + value.has_timestamp = true; + value.timestamp_millis = 1700000000456ULL; + return value; +} + +bool equalMeta(const Telemetry::CustomLocationMeta& left, + const Telemetry::CustomLocationMeta& right) { + return left.has_cease == right.has_cease && left.cease == right.cease && + left.has_expires == right.has_expires && + left.expires_millis == right.expires_millis && + left.has_approx_radius == right.has_approx_radius && + left.approx_radius_meters == right.approx_radius_meters && + left.has_timestamp == right.has_timestamp && + left.timestamp_millis == right.timestamp_millis; +} + +void decodesAndEncodesCanonicalColumbaMetadata() { + Telemetry::CustomLocationMeta decoded{}; + CHECK(Telemetry::decodeCustomLocationMeta(CANONICAL, sizeof(CANONICAL), decoded) == + Telemetry::CustomMetaResult::OK); + CHECK(equalMeta(decoded, expectedMeta())); + + uint8_t encoded[128]{}; + std::size_t written = 0; + CHECK(Telemetry::encodeCustomLocationMeta( + expectedMeta(), encoded, sizeof(encoded), written) == + Telemetry::CustomMetaResult::OK); + CHECK(written == sizeof(CANONICAL)); + CHECK(std::memcmp(encoded, CANONICAL, sizeof(CANONICAL)) == 0); +} + +void distinguishesAbsentFalseTrueAndEmpty() { + constexpr uint8_t explicit_false[] = { + 0x81, 0xa5, 'c', 'e', 'a', 's', 'e', 0xc2, + }; + Telemetry::CustomLocationMeta decoded{}; + CHECK(Telemetry::decodeCustomLocationMeta( + explicit_false, sizeof(explicit_false), decoded) == + Telemetry::CustomMetaResult::OK); + CHECK(decoded.has_cease && !decoded.cease); + + uint8_t encoded[32]{}; + std::size_t written = 99; + CHECK(Telemetry::encodeCustomLocationMeta( + decoded, encoded, sizeof(encoded), written) == + Telemetry::CustomMetaResult::OK); + CHECK(written == sizeof(explicit_false)); + CHECK(std::memcmp(encoded, explicit_false, sizeof(explicit_false)) == 0); + + Telemetry::CustomLocationMeta empty{}; + written = 99; + CHECK(Telemetry::encodeCustomLocationMeta( + empty, encoded, sizeof(encoded), written) == + Telemetry::CustomMetaResult::EMPTY); + CHECK(written == 0); +} + +void acceptsReorderedKeysAndSkipsUnknownNestedValues() { + constexpr uint8_t packed[] = { + 0x83, + 0xa2, 't', 's', 0xcd, 0x03, 0xe8, + 0xa7, 'u', 'n', 'k', 'n', 'o', 'w', 'n', + 0x92, 0x01, 0x81, 0xa1, 'x', 0xc3, + 0xa5, 'c', 'e', 'a', 's', 'e', 0xc3, + }; + Telemetry::CustomLocationMeta decoded{}; + CHECK(Telemetry::decodeCustomLocationMeta(packed, sizeof(packed), decoded) == + Telemetry::CustomMetaResult::OK); + CHECK(decoded.has_timestamp && decoded.timestamp_millis == 1000); + CHECK(decoded.has_cease && decoded.cease); + CHECK(!decoded.has_expires && !decoded.has_approx_radius); +} + +void rejectsMalformedAndPreservesOutput() { + constexpr uint8_t json[] = {'{', '"', 'c', 'e', 'a', 's', 'e', '"', ':', 't', 'r', 'u', 'e', '}'}; + constexpr uint8_t wrong_cease_type[] = { + 0x81, 0xa5, 'c', 'e', 'a', 's', 'e', 0x01, + }; + constexpr uint8_t negative_expiry[] = { + 0x81, 0xa7, 'e', 'x', 'p', 'i', 'r', 'e', 's', 0xff, + }; + constexpr uint8_t duplicate_cease[] = { + 0x82, + 0xa5, 'c', 'e', 'a', 's', 'e', 0xc2, + 0xa5, 'c', 'e', 'a', 's', 'e', 0xc3, + }; + constexpr uint8_t too_many_entries[] = {0xde, 0x00, 0x11}; + constexpr uint8_t huge_string[] = {0x81, 0xdb, 0xff, 0xff, 0xff, 0xff}; + + struct Case { const uint8_t* data; std::size_t size; }; + const Case cases[] = { + {json, sizeof(json)}, + {wrong_cease_type, sizeof(wrong_cease_type)}, + {negative_expiry, sizeof(negative_expiry)}, + {duplicate_cease, sizeof(duplicate_cease)}, + {too_many_entries, sizeof(too_many_entries)}, + {huge_string, sizeof(huge_string)}, + }; + const auto sentinel = expectedMeta(); + for (const auto& item : cases) { + auto output = sentinel; + CHECK(Telemetry::decodeCustomLocationMeta(item.data, item.size, output) == + Telemetry::CustomMetaResult::MALFORMED); + CHECK(equalMeta(output, sentinel)); + } + + for (std::size_t size = 0; size < sizeof(CANONICAL); ++size) { + auto output = sentinel; + CHECK(Telemetry::decodeCustomLocationMeta(CANONICAL, size, output) != + Telemetry::CustomMetaResult::OK); + CHECK(equalMeta(output, sentinel)); + } + + uint8_t trailing[sizeof(CANONICAL) + 1]{}; + std::memcpy(trailing, CANONICAL, sizeof(CANONICAL)); + trailing[sizeof(CANONICAL)] = 0xc0; + auto output = sentinel; + CHECK(Telemetry::decodeCustomLocationMeta( + trailing, sizeof(trailing), output) == + Telemetry::CustomMetaResult::MALFORMED); + CHECK(equalMeta(output, sentinel)); +} + +} // namespace + +int main() { + decodesAndEncodesCanonicalColumbaMetadata(); + distinguishesAbsentFalseTrueAndEmpty(); + acceptsReorderedKeysAndSkipsUnknownNestedValues(); + rejectsMalformedAndPreservesOutput(); + std::cout << "custom location metadata codec: " << passed << " passed, " + << failures << " failed\n"; + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/native/test_custom_location_meta_codec.py b/tests/native/test_custom_location_meta_codec.py new file mode 100644 index 00000000..9758dbb6 --- /dev/null +++ b/tests/native/test_custom_location_meta_codec.py @@ -0,0 +1,23 @@ +"""Compile and execute the portable custom location metadata codec tests.""" + +from pathlib import Path + +from native_test import compile_and_run + +HERE = Path(__file__).resolve().parent +PYXIS_ROOT = HERE.parent.parent + + +def test_custom_location_meta_codec(tmp_path): + ran = compile_and_run( + tmp_path, + name="test_custom_location_meta_codec", + sources=[ + HERE / "test_custom_location_meta_codec.cpp", + PYXIS_ROOT / "lib" / "tdeck_ui" / "Telemetry" / "LocationTelemetryCodec.cpp", + ], + include_dirs=[PYXIS_ROOT / "lib" / "tdeck_ui"], + sanitize=True, + ) + assert "custom location metadata codec:" in ran.stdout + assert "0 failed" in ran.stdout