core(M1): ayla-криптография, конверт, JSON (jsmn), HTTP-клиент

- crypto: KDF Ayla (двойной HMAC, suffix 0x30/31/32; app/dev направления),
  AES-256-CBC с непрерывной цепочкой (iv обновляется mbedtls на месте),
  Java-паддинг >=1 NUL; mode-latch против misuse (encrypt|decrypt);
  zeroize ключей при повторном init; векторы из APK (4 сессии × 4 сообщения,
  включая legacy-приём без NUL) — scripts/gen_kdf_vectors.py.
- envelope: pack/unpack {"enc","sign"}; расшифровка (движение цепочки)
  ДО проверки подписи; сравнение подписи в константном времени;
  extract_seq_no — depth-1 сканер без лимита токенов (OOB после escape
  исправлен, регресс-тесты по ASan-репро ревьюера).
- json: Writer (фикс. буфер, стек глубин, escape, ok()=false при
  переполнении) + Doc на jsmn (64 токена, unescape, overflow-guard).
- httpc: блокирующий POST/PUT для local_reg (статус 200-599, дренаж,
  shutdown перед close).
- third_party/jsmn (MIT, JSMN_STATIC).
- CMake: mbedtls системный (/usr/include/mbedtls3) или FetchContent;
  IDF: PRIV_REQUIRES mbedtls.
- CI: 3 конфигурации — gcc-Release, gcc-Debug+ASan/UBSan, clang-Release;
  6/6 тестов стабильно; ESP-IDF esp32 build complete.
Ревью под-агентом: 3 круга (OOB-блокер + тестовые флаки закрыты), APPROVED.
This commit is contained in:
2026-09-22 15:45:20 +03:00
parent b44004627b
commit e91d4605b1
19 changed files with 2120 additions and 3 deletions

156
tests/ayla/test_json.cpp Normal file
View File

@@ -0,0 +1,156 @@
// Тесты JSON: writer (фиксированный буфер, вложенность, переполнение)
// и парсер (jsmn) — форматы протокола.
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/json.hpp"
using fgl::ayla::json::Doc;
using fgl::ayla::json::Writer;
TEST_CASE("json writer: формы протокола") {
// datapoint-команда (properties)
char buf[512];
Writer w(buf, sizeof(buf));
w.begin_object(); // {
w.key("properties");
w.begin_array();
w.begin_object();
w.key("property");
w.begin_object();
w.key("base_type"); w.string("integer");
w.key("name"); w.string("fan_speed");
w.key("value"); w.integer(3);
w.key("id"); w.string("ab12cd34");
w.end_object();
w.end_object();
w.end_array();
w.end_object();
REQUIRE(w.ok());
CHECK(std::string(buf) ==
"{\"properties\":[{\"property\":{\"base_type\":\"integer\","
"\"name\":\"fan_speed\",\"value\":3,\"id\":\"ab12cd34\"}}]}");
// GET-команда (cmds)
Writer w2(buf, sizeof(buf));
w2.begin_object();
w2.key("cmds");
w2.begin_array();
w2.begin_object();
w2.key("cmd");
w2.begin_object();
w2.key("cmd_id"); w2.integer(5);
w2.key("method"); w2.string("GET");
w2.key("resource"); w2.string("property.json?name=op");
w2.key("data"); w2.string("");
w2.key("uri"); w2.string("/local_lan/property/datapoint.json");
w2.end_object();
w2.end_object();
w2.end_array();
w2.end_object();
REQUIRE(w2.ok());
CHECK(std::string(buf) ==
"{\"cmds\":[{\"cmd\":{\"cmd_id\":5,\"method\":\"GET\","
"\"resource\":\"property.json?name=op\",\"data\":\"\","
"\"uri\":\"/local_lan/property/datapoint.json\"}}]}");
// key_exchange ответ + массив целых + bool/null
Writer w3(buf, sizeof(buf));
w3.begin_object();
w3.key("random_2"); w3.string("abcd1234");
w3.key("time_2"); w3.integer(-123);
w3.key("flags");
w3.begin_array();
w3.integer(1); w3.integer(2); w3.integer(3);
w3.end_array();
w3.key("ok"); w3.boolean(true);
w3.key("nothing"); w3.literal("null");
w3.end_object();
REQUIRE(w3.ok());
CHECK(std::string(buf) ==
"{\"random_2\":\"abcd1234\",\"time_2\":-123,\"flags\":[1,2,3],"
"\"ok\":true,\"nothing\":null}");
}
TEST_CASE("json writer: экранирование и переполнение") {
char buf[64];
Writer w(buf, sizeof(buf));
w.begin_object();
w.key("a\"b\\c"); // ключ с кавычкой и обратным слешем
w.string("x\ny\"z");
w.end_object();
REQUIRE(w.ok());
CHECK(std::string(buf) == "{\"a\\\"b\\\\c\":\"x\\ny\\\"z\"}");
// Переполнение буфера — ok() = false, не выходим за границы.
Writer w2(buf, sizeof(buf));
w2.begin_object();
w2.key("data");
w2.string("0123456789012345678901234567890123456789012345678901234567890123456789");
w.end_object();
CHECK_FALSE(w2.ok());
CHECK(strlen(buf) < sizeof(buf)); // NUL-терминировано всегда
}
TEST_CASE("json parser: форматы протокола") {
// datapoint push
char buf[128];
strcpy(buf, "{\"name\":\"fan_speed\",\"value\":3,\"metadata\":{}}");
Doc doc;
REQUIRE(doc.parse(buf));
char name[32];
REQUIRE(doc.get_string("name", name, sizeof(name)));
CHECK(std::string(name) == "fan_speed");
int64_t value = -1;
REQUIRE(doc.get_int("value", &value));
CHECK(value == 3);
bool b = true;
CHECK_FALSE(doc.get_bool("value", &b)); // не bool
CHECK_FALSE(doc.get_string("absent", name, sizeof(name)));
// key_exchange
strcpy(buf, "{\"key_exchange\":{\"ver\":1,\"proto\":1,\"key_id\":62999,"
"\"random_1\":\"ab+/12\",\"time_1\":-987654321,\"sec\":\"\"}}");
REQUIRE(doc.parse(buf));
int64_t ver = -1, key_id = -1, time_1 = -1;
REQUIRE(doc.get_int("ver", &ver));
REQUIRE(doc.get_int("key_id", &key_id));
REQUIRE(doc.get_int("time_1", &time_1));
char rnd[32];
REQUIRE(doc.get_string("random_1", rnd, sizeof(rnd)));
CHECK(ver == 1);
CHECK(key_id == 62999);
CHECK(time_1 == -987654321);
CHECK(std::string(rnd) == "ab+/12");
// envelope payload
strcpy(buf, "{\"seq_no\":42,\"data\":{\"name\":\"x\",\"value\":true}}");
REQUIRE(doc.parse(buf));
int64_t seq = -1;
REQUIRE(doc.get_int("seq_no", &seq));
CHECK(seq == 42);
bool v = false;
REQUIRE(doc.get_bool("value", &v));
CHECK(v);
// Мусор
strcpy(buf, "not json");
CHECK_FALSE(doc.parse(buf));
strcpy(buf, "[1,2]");
CHECK_FALSE(doc.parse(buf)); // верхний уровень — объект
}
TEST_CASE("json parser: экранированные строки") {
char buf[96];
strcpy(buf, "{\"name\":\"a\\\"b\\\\c\\nd\"}");
Doc doc;
REQUIRE(doc.parse(buf));
char out[32];
REQUIRE(doc.get_string("name", out, sizeof(out)));
CHECK(std::string(out) == "a\"b\\c\nd");
// Не помещается в out
char small[4];
CHECK_FALSE(doc.get_string("name", small, sizeof(small)));
}