tests: SSDP golden fixtures, push_status delta-merge spec, signing trailing-sibling lock (#114)

This commit is contained in:
Santiago Postorivo authored and GitHub committed 2026-10-14 07:16:56 +02:00
1 parent d6e0cb95ff
commit f356b164a6
27 files changed
+899 -53

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Fixtures are byte-for-byte recordings. Never let git convert line endings —
# a checkout that rewrites them changes what the corpus tests.
tests/fixtures/** -text
+47 -21
View File
@@ -778,17 +778,59 @@ if(OBN_BUILD_TESTS)
target_link_libraries(state_persist_test PRIVATE ws2_32)
endif()
add_test(NAME state_persist COMMAND state_persist_test)
endif()
if(OBN_BUILD_TESTS AND NOT WIN32)
# Live test: skipped unless OBN_PRINTER_IP etc. are exported when ctest
# runs. Links against the same library as the plugin so it can exercise
# obn::Agent directly.
# Hermetic unit tests for obn::signing — exercises key loading, canonical
# JSON ordering, signature round-trip and envelope structure validation.
# Generates ephemeral RSA keys at runtime; no on-disk secrets required.
add_executable(signing_test tests/signing_test.cpp
tests/config_stub.cpp
src/json_lite.cpp
src/log.cpp
src/os_compat.cpp
src/signing.cpp)
target_include_directories(signing_test PRIVATE include ${OBN_GENERATED_DIR})
target_compile_definitions(signing_test PRIVATE
OBN_VERSION_STRING="${OBN_VERSION}"
ABI_VERSION=${OBN_ABI_VERSION_HEX})
target_link_libraries(signing_test PRIVATE OpenSSL::SSL OpenSSL::Crypto)
if(WIN32)
# os_compat.cpp holds the Winsock wrappers (same reason as state_persist)
target_link_libraries(signing_test PRIVATE ws2_32)
endif()
add_test(NAME signing COMMAND signing_test)
# DOC-01 golden-fixture suite for the SSDP parser: plain main()+CHECK
# (json_lite_test.cpp style), no test framework in the configure step.
# Hermetic (no socket). WORKING_DIRECTORY is how fixtures resolve at
# runtime — not a compile definition, because the workspace path contains
# a space (D-08). src/json_lite.cpp is linked because the test parses the
# emitted device JSON with obn::json::parse (not used by ssdp.cpp itself).
add_executable(ssdp_listener_test tests/ssdp_listener_test.cpp
src/json_lite.cpp
src/log.cpp
src/os_compat.cpp
src/ssdp.cpp)
target_include_directories(ssdp_listener_test PRIVATE include)
if(WIN32)
# log.cpp needs os_compat.cpp for strcasecmp_portable, and that
# translation unit also holds the Winsock wrappers — mirror
# state_persist above.
target_link_libraries(ssdp_listener_test PRIVATE ws2_32)
endif()
add_test(NAME ssdp_listener COMMAND ssdp_listener_test)
set_tests_properties(ssdp_listener PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures")
endif()
if(OBN_BUILD_TESTS AND NOT WIN32)
# Dev tool, NOT a test (D-07): live UDP :2021 sniffer moved verbatim
# out of tests/ssdp_listener_test.cpp. Deliberately has no add_test —
# it binds a real socket and must never run under ctest.
add_executable(ssdp_sniffer tools/ssdp_sniffer/main.cpp
src/log.cpp
src/os_compat.cpp
src/ssdp.cpp)
target_include_directories(ssdp_sniffer PRIVATE include)
add_executable(ftps_live_test tests/ftps_live_test.cpp
src/config.cpp
@@ -930,22 +972,6 @@ if(OBN_BUILD_TESTS AND NOT WIN32)
)
add_test(NAME lan_live COMMAND lan_live_test)
# Hermetic unit tests for obn::signing — exercises key loading, canonical
# JSON ordering, signature round-trip and envelope structure validation.
# Generates ephemeral RSA keys at runtime; no on-disk secrets required.
add_executable(signing_test tests/signing_test.cpp
tests/config_stub.cpp
src/json_lite.cpp
src/log.cpp
src/os_compat.cpp
src/signing.cpp)
target_include_directories(signing_test PRIVATE include ${OBN_GENERATED_DIR})
target_compile_definitions(signing_test PRIVATE
OBN_VERSION_STRING="${OBN_VERSION}"
ABI_VERSION=${OBN_ABI_VERSION_HEX})
target_link_libraries(signing_test PRIVATE OpenSSL::SSL OpenSSL::Crypto)
add_test(NAME signing COMMAND signing_test)
add_executable(cert_store_test tests/cert_store_test.cpp
tests/config_stub.cpp
src/cert_store.cpp
+12
View File
@@ -339,6 +339,9 @@ static std::string json_str_escape(const std::string& s)
return out;
}
// Emits {"header":…,"<root_key>":dump} ONLY — trailing siblings in the input
// payload are not carried. See the invariant comment at to_sign (maybe_sign)
// below, locked by test_trailing_sibling_reconstruction in tests/signing_test.cpp.
// Builds the complete signed envelope JSON string.
std::string build_envelope(const std::string& to_sign,
const std::string& sig_b64,
@@ -388,6 +391,15 @@ std::string maybe_sign(const std::string& payload_json, EVP_PKEY* device_pub,
build_command_dump(root_key, payload_json, device_pub, developer_mode);
if (dump.empty()) return payload_json; // malformed; pass through
// SINGLE-ROOT WRAPPER INVARIANT (locked by test_trailing_sibling_reconstruction
// in tests/signing_test.cpp): to_sign is exactly {"<root_key>":<dump>} —
// family key first, single root, never sorted at the root level; the dump
// here is byte-identical to the dump build_envelope emits (it receives the
// same string), so payload_len == bytes signed == wire bytes.
// A payload with trailing siblings (e.g. {"print":...,"user_id":...}) currently
// DROPS the sibling — pinned, not fixed (research/10.04 Signing consensus;
// diverges from the #72/farm consensus, which signs envelope-minus-header
// including user_id).
const std::string to_sign = "{\"" + root_key + "\":" + dump + "}";
const std::string sig_b64 = rsa_sha256_sign_b64(
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sean Callan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+77
View File
@@ -0,0 +1,77 @@
# Vendored Fixtures — doomspork/PandaSpy (MIT)
These files are unmodified, verbatim copies of fixture recordings from
[doomspork/PandaSpy](https://github.com/doomspork/PandaSpy), licensed under the
MIT License — Copyright (c) 2026 Sean Callan.
- Source repo: https://github.com/doomspork/PandaSpy
- License: MIT — full text vendored at [LICENSE.PandaSpy](LICENSE.PandaSpy)
- Raw SSDP source: https://raw.githubusercontent.com/doomspork/PandaSpy/main/fixtures/ssdp/
- Raw sequence source: https://raw.githubusercontent.com/doomspork/PandaSpy/main/fixtures/sequences/p1s-print-lifecycle/
## SHA-256 manifest
Every fixture byte is pinned here; the SSDP suite reads fixtures at runtime
and fails loudly on a missing file (D-08), while this manifest guards
against silent content edits. Regenerate with `sha256sum` over the two
directories and diff against this block.
```
# ssdp/
d8c25b0be7ea457d8b56ea2537d6f0bb0c8b69e1a04be41892d25ff9bc846140 ssdp/synthetic-msearch-echo.txt
3bef7752b26eb388d62c5bbcb7681f01e1be0658ca82e827b8e24bd5372253aa ssdp/synthetic-not-ssdp.txt
fe3025744ef2654f80db90de2f782b54e107fb5856c8ab5ba7207fc6d3f23afe ssdp/synthetic-notify-colonless-header.txt
cbe3587a31ab5ab8eb0cb0e6be713d1a7297ad53681eea05b96ec368fc91493a ssdp/synthetic-notify-https-location.txt
7601dc2ac5d04cce68790f0a81ad32e3512c9720850eb613bf77ee7fd97785f7 ssdp/synthetic-notify-lf-lowercase-a1mini.txt
b94b8b6553f7f586d1834a080e2f08f106b7a20a4f1ef58dbb1bcdd5fa934bb1 ssdp/synthetic-notify-names-nothing.txt
5f39294f3abeb7bd623314b28f523dd931ad5071ec31eee95ccd400b49203a73 ssdp/synthetic-notify-no-location-h2d.txt
b59b5e57a489d40ce1d96135baf5a526f155af2e34a3578a256916363864f916 ssdp/synthetic-notify-unknown-model.txt
a1c82766646f95b9cb911130f9241fa336b6903e1d4726ded3ac37912328d530 ssdp/synthetic-notify-url-location.txt
60581210ef84806f97f7d0d1e5cd43a69bd24c566063028565e488108f65c059 ssdp/synthetic-notify-x1c.txt
0954b4e49067bf34083684feedf8e0009a28af3aba5d77b1c47e07d30601b2a6 ssdp/synthetic-search-response-404.txt
4b674821d6d99b9f5001260dbb9466eb2475eb220e0f9b763a694aaadf2fadd2 ssdp/synthetic-search-response-p1s.txt
# sequences/p1s-print-lifecycle/
67a495afec3a7015eca2e22f8b80a81c92f5dd1a650c30f1d1649a1d6365a416 sequences/p1s-print-lifecycle/00-pushall.json
acff29c28f7f20897f2c1078bd7b450e756dceb417f7aa76d8a02dd2ff9dcac2 sequences/p1s-print-lifecycle/01-delta-temps.json
e5e6593bcb5dc73b3b0c3bd811baf87a4e42561d4c8e48a338fab19dd5682dad sequences/p1s-print-lifecycle/02-delta-progress.json
e4253394710a98f75cd3c95addf7d2a142bb4df0b2ac98c165887215cc55673e sequences/p1s-print-lifecycle/03-delta-tray-switch.json
d512d7fceabb9b89265f1e053856de3ea9101f6b3f4c3860df7e564469dce1e1 sequences/p1s-print-lifecycle/04-delta-null-clears-wifi.json
cb33381a4026304cff97a7f0e0cec3e473b9b6de765fdfebf825f09a3d175eb6 sequences/p1s-print-lifecycle/05-delta-pause.json
85c475686240bbc0616459535113e6e3cc98dce41d467de6670c464e8668622a sequences/p1s-print-lifecycle/06-delta-finish.json
```
## SSDP inventory (12 files — live GitHub API listing of upstream `fixtures/ssdp/`, 2026-09-29)
- synthetic-msearch-echo.txt
- synthetic-not-ssdp.txt
- synthetic-notify-colonless-header.txt
- synthetic-notify-https-location.txt
- synthetic-notify-lf-lowercase-a1mini.txt
- synthetic-notify-names-nothing.txt
- synthetic-notify-no-location-h2d.txt
- synthetic-notify-unknown-model.txt
- synthetic-notify-url-location.txt
- synthetic-notify-x1c.txt
- synthetic-search-response-404.txt
- synthetic-search-response-p1s.txt
Upstream serves **12** SSDP fixtures; our survey notes (INTERESTING_REPOS.md:53,
decision D-02) said **13** — the 13-vs-12 count discrepancy is recorded here
deliberately (OQ-2). No 13th fixture is ever invented.
## Sequence inventory (7 files, `p1s-print-lifecycle/`)
Raw material for a future `push_status` delta-merge test — the repo has no
push_status accumulator to call yet, so no test drives them today (they are
kept for the day one exists):
1. `00-pushall.json`
2. `01-delta-temps.json`
3. `02-delta-progress.json`
4. `03-delta-tray-switch.json`
5. `04-delta-null-clears-wifi.json`
6. `05-delta-pause.json`
7. `06-delta-finish.json`
All fixture bytes are guarded by `.gitattributes` (`tests/fixtures/** -text`)
so git never converts line endings — the LF-only case stays LF-only.
@@ -0,0 +1,56 @@
{
"print": {
"command": "push_status",
"msg": 0,
"sequence_id": "1",
"gcode_state": "RUNNING",
"gcode_file": "benchy_plate_1.gcode",
"subtask_name": "benchy",
"print_type": "local",
"stg_cur": 0,
"mc_percent": 9,
"mc_remaining_time": 118,
"layer_num": 12,
"total_layer_num": 137,
"bed_temper": 59.8,
"bed_target_temper": 60.0,
"nozzle_temper": 219.4,
"nozzle_target_temper": 220.0,
"chamber_temper": 33.0,
"cooling_fan_speed": "15",
"big_fan1_speed": "0",
"big_fan2_speed": "9",
"heatbreak_fan_speed": "15",
"spd_lvl": 2,
"spd_mag": 100,
"nozzle_diameter": "0.4",
"nozzle_type": "stainless_steel",
"wifi_signal": "-52dBm",
"sdcard": true,
"print_error": 0,
"hms": [],
"lights_report": [{ "node": "chamber_light", "mode": "on" }],
"ams": {
"ams_exist_bits": "1",
"tray_exist_bits": "f",
"tray_now": "1",
"tray_pre": "255",
"tray_tar": "1",
"version": 402,
"ams": [
{
"id": "0",
"humidity": "3",
"temp": "29.1",
"tray": [
{ "id": "0", "tray_type": "PLA", "tray_color": "FFFFFFFF", "remain": 71 },
{ "id": "1", "tray_type": "PLA", "tray_color": "00AE42FF", "remain": 92 },
{ "id": "2", "tray_type": "PETG", "tray_color": "0A2989FF", "remain": 34 },
{ "id": "3", "tray_type": "TPU", "tray_color": "F5A623FF", "remain": -1 }
]
}
]
},
"vt_tray": { "id": "254", "tray_type": "", "tray_color": "00000000" }
}
}
@@ -0,0 +1,9 @@
{
"print": {
"command": "push_status",
"msg": 1,
"sequence_id": "2",
"bed_temper": 60.1,
"nozzle_temper": 220.2
}
}
@@ -0,0 +1,10 @@
{
"print": {
"command": "push_status",
"msg": 1,
"sequence_id": "3",
"mc_percent": 42,
"mc_remaining_time": 96,
"layer_num": 57
}
}
@@ -0,0 +1,13 @@
{
"print": {
"command": "push_status",
"msg": 1,
"sequence_id": "4",
"stg_cur": 4,
"ams": {
"tray_now": "2",
"tray_pre": "1",
"tray_tar": "2"
}
}
}
@@ -0,0 +1,9 @@
{
"print": {
"command": "push_status",
"msg": 1,
"sequence_id": "5",
"stg_cur": 0,
"wifi_signal": null
}
}
@@ -0,0 +1,9 @@
{
"print": {
"command": "push_status",
"msg": 1,
"sequence_id": "6",
"gcode_state": "PAUSE",
"stg_cur": 16
}
}
@@ -0,0 +1,15 @@
{
"print": {
"command": "push_status",
"msg": 1,
"sequence_id": "7",
"gcode_state": "FINISH",
"stg_cur": -1,
"mc_percent": 100,
"mc_remaining_time": 0,
"layer_num": 137,
"nozzle_target_temper": 0.0,
"bed_target_temper": 0.0,
"cooling_fan_speed": "0"
}
}
+6
View File
@@ -0,0 +1,6 @@
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:2021
MAN: "ssdp:discover"
MX: 3
ST: urn:bambulab-com:device:3dprinter:1
+6
View File
@@ -0,0 +1,6 @@
{
"service": "_bambulab._tcp.local",
"port": 2021,
"txt": ["provenance=synthetic", "note=chatter on the same socket, not SSDP"]
}
@@ -0,0 +1,7 @@
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:2021
this-line-has-no-colon
Location: 192.168.0.8
USN: 01P00A000000000
DevModel.bambu.com: C12
@@ -0,0 +1,6 @@
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:2021
Location: https://192.168.0.11:8883/desc
USN: 05A00A000000000
DevModel.bambu.com: N2S
@@ -0,0 +1,13 @@
NOTIFY * HTTP/1.1
host: 239.255.255.250:2021
server: UPnP/1.0
location: 192.168.0.4
nt: urn:bambulab-com:device:3dprinter:1
usn: 03900A000000000
cache-control: max-age=1800
devmodel.bambu.com: N1
devname.bambu.com: REDACTED-NAME
devsignal.bambu.com: -67
devconnect.bambu.com: lan
devbind.bambu.com: free
@@ -0,0 +1,5 @@
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:2021
Location: 192.168.0.9
NT: urn:bambulab-com:device:3dprinter:1
@@ -0,0 +1,12 @@
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:2021
Server: UPnP/1.0
NT: urn:bambulab-com:device:3dprinter:1
USN: 09400A000000000
Cache-Control: max-age=1800
DevModel.bambu.com: O1D
DevName.bambu.com: REDACTED-NAME
DevSignal.bambu.com: -51
DevConnect.bambu.com: lan
DevBind.bambu.com: free
+13
View File
@@ -0,0 +1,13 @@
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:2021
Server: UPnP/1.0
Location: 192.168.0.9
NT: urn:bambulab-com:device:3dprinter:1
USN: 0XX00A000000000
Cache-Control: max-age=1800
DevModel.bambu.com: 3DPrinter-X9-Hyper
DevName.bambu.com: REDACTED-NAME
DevSignal.bambu.com: -39
DevConnect.bambu.com: lan
DevBind.bambu.com: free
+13
View File
@@ -0,0 +1,13 @@
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:2021
Server: UPnP/1.0
Location: http://192.168.0.7:8883/desc
NT: urn:bambulab-com:device:3dprinter:1
USN: 01S00A000000000
Cache-Control: max-age=1800
DevModel.bambu.com: C11
DevName.bambu.com: REDACTED-NAME
DevSignal.bambu.com: -62
DevConnect.bambu.com: lan
DevBind.bambu.com: free
+13
View File
@@ -0,0 +1,13 @@
NOTIFY * HTTP/1.1
HOST: 239.255.255.250:2021
Server: UPnP/1.0
Location: 192.168.0.2
NT: urn:bambulab-com:device:3dprinter:1
USN: 00M09A000000000
Cache-Control: max-age=1800
DevModel.bambu.com: 3DPrinter-X1-Carbon
DevName.bambu.com: REDACTED-NAME
DevSignal.bambu.com: -44
DevConnect.bambu.com: lan
DevBind.bambu.com: free
+4
View File
@@ -0,0 +1,4 @@
HTTP/1.1 404 Not Found
HOST: 239.255.255.250:2021
Server: UPnP/1.0
+13
View File
@@ -0,0 +1,13 @@
HTTP/1.1 200 OK
HOST: 239.255.255.250:2021
Server: UPnP/1.0
Location: 192.168.0.3
ST: urn:bambulab-com:device:3dprinter:1
USN: 01P00A000000000
Cache-Control: max-age=1800
DevModel.bambu.com: C12
DevName.bambu.com: REDACTED-NAME
DevSignal.bambu.com: -58
DevConnect.bambu.com: lan
DevBind.bambu.com: free
+71
View File
@@ -1,5 +1,11 @@
// Tests for src/signing.cpp — classifier, envelope structure, and signature
// verification using a freshly generated RSA test keypair.
//
// D-07 header note (requirement-letter-vs-reality deviation, recorded per the
// Phase 2 precedent): this file follows the repo convention (int main() +
// fail_count + CHECK), NOT Catch2 — REQUIREMENTS calls the new case a
// "Catch2 test", but only ssdp_listener_test.cpp is actually Catch2 in this
// tree; Catch2 conversion of this file is deferred (test-infrastructure work).
#include "obn/signing.hpp"
#include "obn/config.hpp"
@@ -19,7 +25,17 @@
#include <iostream>
#include <memory>
#include <string>
#ifdef _WIN32
// MSVC port (Pitfall 3): no <unistd.h> — POSIX names are mapped to their
// underscore-prefixed CRT equivalents below; POSIX branch stays untouched.
#include <cstdlib>
#include <io.h>
#define close _close
#define open _open
#define unlink _unlink
#else
#include <unistd.h>
#endif
#include <vector>
#define CHECK(cond) do { \
@@ -525,6 +541,42 @@ namespace obn::config {
std::string& test_dir();
}
static int test_trailing_sibling_reconstruction()
{
// SIGN-01 / D-08: trailing sibling AFTER the root key.
// PINNED OBSERVED BEHAVIOR: maybe_sign/build_envelope emit
// {"header":…,"print":<dump>} only — the trailing sibling (user_id) is
// silently dropped, which DIVERGES from the #72/farm consensus ("signature
// covers the whole envelope minus header, including user_id"), annotated
// in research/10.04 §Signing consensus. Pinned per the Phase 2 D-09
// register and NOT fixed (PROJECT.md Out of Scope: no signing change).
const std::string payload =
R"({"print":{"command":"pause","sequence_id":"11"},"user_id":"u_42"})";
const std::string env = obn::signing::maybe_sign(payload); // no device key → hermetic
CHECK(env != payload); // signed, not passed through
auto val = obn::json::parse(env);
CHECK(val);
// (D-08.1) Byte-exact wire match: reconstruct the signed string FROM THE
// EMITTED envelope — the wire bytes are the oracle, not the input bytes.
std::string dump = val->find("print").dump();
std::string to_sign = "{\"print\":" + dump + "}";
double plen = val->find("header.payload_len").as_number();
CHECK(static_cast<std::size_t>(plen) == to_sign.size());
// Envelope must end with the literal tail: ,"print":<dump>}
std::string tail = ",\"print\":" + dump + "}";
CHECK(env.size() >= tail.size());
CHECK(env.compare(env.size() - tail.size(), tail.size(), tail) == 0);
// (D-08.2) Signature round-trip against the wire-reconstructed signed string.
CHECK(verify_b64_sig(to_sign, val->find("header.sign_string").as_string()));
// (D-08.3) Sibling pin: dropped trailing sibling is the observed outcome.
CHECK(val->find("user_id").kind() == obn::json::Value::Kind::Null);
CHECK(env.find("\"user_id\"") == std::string::npos);
return 0;
}
int main()
{
// Generate fresh RSA-2048 keypair for signing tests.
@@ -532,10 +584,28 @@ int main()
if (!g_test_key) { std::cerr << "EVP_RSA_gen failed\n"; return 1; }
// Write private key to a temp PEM file and point the config at it.
#ifdef _WIN32
// Per-run unique temp path — never a fixed predictable filename: the
// XXXXXX template is uniquified per run, then created with _O_EXCL so
// parallel/repeated runs cannot clobber each other's key files.
char tmp_path[512];
const char* tmp_dir = std::getenv("TEMP");
std::snprintf(tmp_path, sizeof(tmp_path), "%s\\signing_test_XXXXXX",
(tmp_dir && *tmp_dir) ? tmp_dir : ".");
if (_mktemp_s(tmp_path, sizeof(tmp_path)) != 0) {
std::cerr << "_mktemp_s failed\n";
EVP_PKEY_free(g_test_key);
return 1;
}
int fd = _open(tmp_path, _O_CREAT | _O_EXCL | _O_WRONLY, 0600);
if (fd < 0) { std::cerr << "_open temp failed\n"; EVP_PKEY_free(g_test_key); return 1; }
close(fd);
#else
char tmp_path[] = "/tmp/signing_test_XXXXXX";
int fd = mkstemp(tmp_path);
if (fd < 0) { std::cerr << "mkstemp failed\n"; EVP_PKEY_free(g_test_key); return 1; }
close(fd);
#endif
std::string pem_path = std::string(tmp_path) + ".pem";
int pem_fd = open(pem_path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
@@ -577,6 +647,7 @@ int main()
if (test_param_enc_idempotent() != 0) rc = 1;
if (test_blockwise_multiblock_roundtrip() != 0) rc = 1;
if (test_liveview_prepare_ttcode_encrypted() != 0) rc = 1;
if (test_trailing_sibling_reconstruction() != 0) rc = 1;
if (rc == 0) std::cout << "signing_test: ok\n";
+394 -32
View File
@@ -1,42 +1,404 @@
// Standalone SSDP sniffer: binds to UDP :2021 and prints every Bambu-looking
// JSON it builds from the incoming packets. Meant as a quick-feedback tool
// while developing the parser; also doubles as a sanity check in CI when an
// env-provided dummy broadcaster is available.
// DOC-01 golden-fixture suite for the SSDP parser (D-05).
//
// Every vendored packet under tests/fixtures/ssdp/ (tests/fixtures/README.md
// inventory, 12 files, D-02) is read at runtime, pushed through the
// production obn::ssdp::parse, and the emitted device-info JSON is asserted
// field by field (D-10 — never whole-string equality). D-11 coverage depth:
// start-line acceptance + header bag + JSON for both spoof dialects.
//
// Style: plain main() + CHECK, same as json_lite_test.cpp — the rest of the
// suite never pulls a test framework into the configure step.
//
// Scope: this suite locks parse() + to_device_info_json(), the bare parser.
// It says nothing about what Studio receives: Discovery's recv loop drops
// packets whose USN or DevModel is missing/empty (src/ssdp.cpp:337-339) and
// fills the sender IP when Location is absent (src/ssdp.cpp:343-347), so the
// empty-identity / empty-dev_ip outputs pinned below never reach the slicer
// through the listener path. Where an observation differs from what the bare
// parser does, the comment carries a `GAP` marker. No production behavior is
// changed to satisfy a fixture — that is absolute this phase.
//
// The live UDP sniffer that used to live in this file was moved verbatim to
// tools/ssdp_sniffer/main.cpp (D-05/D-07); it is not a test and is
// deliberately unregistered in ctest.
#include "obn/json_lite.hpp"
#include "obn/ssdp.hpp"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <thread>
int main(int argc, char** argv)
static int fail_count = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \
#cond); \
++fail_count; \
} \
} while (0)
namespace {
// D-08: a missing or renamed fixture must fail loudly — print FAIL with
// the absolute path (the ctest working directory is tests/fixtures, and
// the workspace path contains a space) and count the failure so the
// suite can never go green on a silent skip.
bool read_fixture_impl(const char* rel, std::string& out, int line)
{
int port = 2021;
int seconds = 15;
if (argc > 1) seconds = std::atoi(argv[1]);
if (argc > 2) port = std::atoi(argv[2]);
obn::ssdp::Discovery d;
std::atomic<int> count{0};
std::mutex stdout_mu;
if (!d.start(port, [&](std::string json) {
std::lock_guard<std::mutex> lk(stdout_mu);
std::printf("[ssdp] %s\n", json.c_str());
++count;
}))
{
std::fprintf(stderr, "ssdp start on :%d failed\n", port);
return 1;
std::ifstream in(rel, std::ios::binary);
if (!in) {
std::error_code ec;
const std::string abs = std::filesystem::absolute(rel, ec).string();
std::fprintf(stderr, "FAIL %s:%d: cannot open fixture: %s (absolute path: %s)\n",
__FILE__, line, rel, abs.c_str());
++fail_count;
out.clear();
return false;
}
std::ostringstream ss;
ss << in.rdbuf();
out = ss.str();
return true;
}
std::this_thread::sleep_for(std::chrono::seconds(seconds));
d.stop();
std::printf("received %d printer advertisements in %ds on :%d\n",
count.load(), seconds, port);
return count.load() > 0 ? 0 : 2;
// Shared post-parse step: to_device_info_json -> obn::json::parse.
bool device_doc(const obn::ssdp::Headers& h, std::string& storage,
obn::json::Value& out)
{
storage = obn::ssdp::to_device_info_json(h);
auto doc = obn::json::parse(storage);
if (!doc.has_value()) {
std::fprintf(stderr, "FAIL %s: emitted device JSON does not parse\n", __FILE__);
++fail_count;
return false;
}
out = *doc;
return true;
}
} // namespace
// Call sites keep the read_fixture(rel, out) shape the plan specifies;
// the line number for the loud-failure message comes from the call site.
#define read_fixture(rel, out) read_fixture_impl(rel, out, __LINE__)
// --------------------------------------------------------------------------
// DOC-01 mandated behaviors
// --------------------------------------------------------------------------
// NOTIFY + NT dialect (D-11): start line accepted, NT survives verbatim,
// field-level JSON emitted from the header bag.
static void test_notify_nt_golden()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-x1c.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
// Header bag: NOTIFY dialect, NT must survive verbatim.
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
CHECK(h.value("location") == "192.168.0.2");
// Field-level JSON checks (D-10). Pinned to the values this fixture
// actually carries — the plan quoted the colonless fixture's values
// for the tracer, pin-observed per D-12.
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
CHECK(doc.find("dev_id").as_string() == "00M09A000000000");
CHECK(doc.find("dev_ip").as_string() == "192.168.0.2");
CHECK(doc.find("dev_type").as_string() == "3DPrinter-X1-Carbon");
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
CHECK(doc.find("connect_type").as_string() == "lan");
CHECK(doc.find("bind_state").as_string() == "free");
}
// HTTP/1.1 200 OK + ST dialect (D-11): response start line accepted, ST
// captured into the header bag, plus field-level JSON.
static void test_search_response_st_dialect()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-search-response-p1s.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h)); // start line accepted
CHECK(h.value("st") == "urn:bambulab-com:device:3dprinter:1");
CHECK(h.value("location") == "192.168.0.3");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
CHECK(doc.find("dev_id").as_string() == "01P00A000000000");
CHECK(doc.find("dev_ip").as_string() == "192.168.0.3");
CHECK(doc.find("dev_type").as_string() == "C12");
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
CHECK(doc.find("connect_type").as_string() == "lan");
CHECK(doc.find("bind_state").as_string() == "free");
}
// LF-only line endings + lowercase header names (DOC-01 mandated): keys are
// lowercased on ingest anyway (obn::ssdp::Headers contract), values verbatim.
static void test_lf_lowercase_headers()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-lf-lowercase-a1mini.txt", buf)) return;
CHECK(buf.find("\r") == std::string::npos); // fixture is genuinely LF-only
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
CHECK(h.value("host") == "239.255.255.250:2021");
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
CHECK(h.value("location") == "192.168.0.4");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
CHECK(doc.find("dev_id").as_string() == "03900A000000000");
CHECK(doc.find("dev_ip").as_string() == "192.168.0.4");
CHECK(doc.find("dev_type").as_string() == "N1");
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
CHECK(doc.find("connect_type").as_string() == "lan");
CHECK(doc.find("bind_state").as_string() == "free");
}
// Colonless header line (DOC-01 mandated): the line is dropped from the
// bag (src/ssdp.cpp:133 skips lines without ':'), every other header
// still parses.
static void test_colonless_line_dropped()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-colonless-header.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
// Dropped at src/ssdp.cpp:133 — absent from the bag, not "".
CHECK(h.get("this-line-has-no-colon") == nullptr);
// Surrounding headers survive.
CHECK(h.value("host") == "239.255.255.250:2021");
CHECK(h.value("location") == "192.168.0.8");
CHECK(h.value("usn") == "01P00A000000000");
CHECK(h.value("devmodel.bambu.com") == "C12");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
CHECK(doc.find("dev_id").as_string() == "01P00A000000000");
CHECK(doc.find("dev_ip").as_string() == "192.168.0.8");
CHECK(doc.find("dev_type").as_string() == "C12");
}
// No Location header (DOC-01 mandated): the bare parser emits dev_ip="".
static void test_no_location_parser_dev_ip_empty()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-no-location-h2d.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
CHECK(h.get("location") == nullptr);
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
// GAP (bare parser only): with no Location header, dev_ip is "" — the
// sender-address fallback lives in Discovery's recv loop
// (src/ssdp.cpp:343-347) and is unreachable from the pure
// parse/to_device_info_json API. The listener fills the real source IP
// before anything reaches Studio, so this pins the parser, not the
// slicer (D-09, Pitfall 9). PandaSpy fills the sender IP the same way.
CHECK(doc.find("dev_ip").as_string() == "");
CHECK(doc.find("dev_id").as_string() == "09400A000000000");
CHECK(doc.find("dev_type").as_string() == "O1D");
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
}
// --------------------------------------------------------------------------
// Non-mandated extras — pinned observed behavior (D-12: pin now + annotate,
// never fix)
// --------------------------------------------------------------------------
// M-SEARCH probe echoed on the socket: accepted (start line has HTTP/1.),
// but carries no USN/Location/DevModel, so the emitted device JSON is all
// empty strings.
static void test_msearch_probe_accepted()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-msearch-echo.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
CHECK(h.value("st") == "urn:bambulab-com:device:3dprinter:1");
CHECK(h.value("man") == "\"ssdp:discover\"");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
// GAP (bare parser only): a discovery probe (not an advertisement) still
// yields a Studio-shaped device JSON with empty identity fields — pinned
// as-is (D-12). The listener path never forwards it: Discovery drops
// packets without USN/DevModel (src/ssdp.cpp:337-339).
CHECK(doc.find("dev_id").as_string() == "");
CHECK(doc.find("dev_ip").as_string() == "");
CHECK(doc.find("dev_type").as_string() == "");
}
// Non-SSDP chatter on UDP :2021: no HTTP/1. in the start line -> rejected.
static void test_non_ssdp_rejected()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-not-ssdp.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(!obn::ssdp::parse(buf.data(), buf.size(), h)); // start line gate, src/ssdp.cpp:121
}
// https:// Location: accepted and passed through to dev_ip verbatim.
static void test_https_location_verbatim()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-https-location.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
CHECK(h.value("location") == "https://192.168.0.11:8883/desc");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
// GAP: dev_ip is the raw Location string, not an extracted host — the
// parser never URL-parses Location (src/ssdp.cpp:154). Studio receives
// "https://192.168.0.11:8883/desc" where it expects a bare IP; pinned
// as-is (D-12). (Location IS present, so the Discovery source-IP
// fallback does not apply here.)
CHECK(doc.find("dev_ip").as_string() == "https://192.168.0.11:8883/desc");
CHECK(doc.find("dev_id").as_string() == "05A00A000000000");
CHECK(doc.find("dev_type").as_string() == "N2S");
// No DevName header in this packet.
CHECK(doc.find("dev_name").as_string() == "");
}
// http:// URL Location with port: same verbatim pass-through as above.
static void test_url_location_verbatim()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-url-location.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
// GAP: raw URL lands in dev_ip (see https-location case) — pinned, not
// fixed (D-12).
CHECK(doc.find("dev_ip").as_string() == "http://192.168.0.7:8883/desc");
CHECK(doc.find("dev_id").as_string() == "01S00A000000000");
CHECK(doc.find("dev_type").as_string() == "C11");
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
}
// Advertisement with NT+Location but no USN/DevModel/DevName at all.
static void test_missing_identity_headers()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-names-nothing.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
CHECK(h.value("nt") == "urn:bambulab-com:device:3dprinter:1");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
// GAP (bare parser only): an advertisement with no USN/model/name still
// emits a complete device JSON with empty identity instead of being
// dropped — pinned as-is (D-12). Discovery filters it out before the
// callback (src/ssdp.cpp:337-339), so Studio never keys on "".
CHECK(doc.find("dev_id").as_string() == "");
CHECK(doc.find("dev_ip").as_string() == "192.168.0.9");
CHECK(doc.find("dev_type").as_string() == "");
CHECK(doc.find("dev_name").as_string() == "");
}
// Unknown model string: parser is model-agnostic, passes it through.
static void test_unknown_model_passthrough()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-notify-unknown-model.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h));
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
// GAP: no model validation — "3DPrinter-X9-Hyper" passes the listener's
// non-empty DevModel check and reaches Studio verbatim with no
// unknown-model flag (D-12 pin).
CHECK(doc.find("dev_type").as_string() == "3DPrinter-X9-Hyper");
CHECK(doc.find("dev_id").as_string() == "0XX00A000000000");
CHECK(doc.find("dev_ip").as_string() == "192.168.0.9");
CHECK(doc.find("dev_name").as_string() == "REDACTED-NAME");
}
// HTTP error response. Pitfall 10: the start-line gate is a substring
// check for "HTTP/1." (src/ssdp.cpp:121), so a 404 is ACCEPTED.
static void test_404_response_accepted()
{
std::string buf;
if (!read_fixture("ssdp/synthetic-search-response-404.txt", buf)) return;
obn::ssdp::Headers h;
CHECK(obn::ssdp::parse(buf.data(), buf.size(), h)); // accepted, not rejected
CHECK(h.value("host") == "239.255.255.250:2021");
std::string json;
obn::json::Value doc;
if (!device_doc(h, json, doc)) return;
// GAP (bare parser only): an HTTP error response parses and emits
// empty-identity device JSON instead of being rejected — pin accepted
// (src/ssdp.cpp:121, Pitfall 10, D-12). This fixture carries no
// USN/DevModel, so the listener drops it at src/ssdp.cpp:337-339 and
// nothing reaches Studio.
CHECK(doc.find("dev_id").as_string() == "");
CHECK(doc.find("dev_ip").as_string() == "");
CHECK(doc.find("dev_type").as_string() == "");
}
int main()
{
test_notify_nt_golden();
test_search_response_st_dialect();
test_lf_lowercase_headers();
test_colonless_line_dropped();
test_no_location_parser_dev_ip_empty();
test_msearch_probe_accepted();
test_non_ssdp_rejected();
test_https_location_verbatim();
test_url_location_verbatim();
test_missing_identity_headers();
test_unknown_model_passthrough();
test_404_response_accepted();
// D-08 guard: read_fixture increments fail_count and prints an absolute
// path on a missing/renamed file; any non-zero count means the suite
// would otherwise report success without executing assertions.
CHECK(fail_count == 0);
if (fail_count == 0)
std::printf("ssdp_listener_test: ok\n");
return fail_count == 0 ? 0 : 1;
}
+42
View File
@@ -0,0 +1,42 @@
// Standalone SSDP sniffer: binds to UDP :2021 and prints every Bambu-looking
// JSON it builds from the incoming packets. Meant as a quick-feedback tool
// while developing the parser; also doubles as a sanity check in CI when an
// env-provided dummy broadcaster is available.
#include "obn/ssdp.hpp"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <string>
#include <thread>
int main(int argc, char** argv)
{
int port = 2021;
int seconds = 15;
if (argc > 1) seconds = std::atoi(argv[1]);
if (argc > 2) port = std::atoi(argv[2]);
obn::ssdp::Discovery d;
std::atomic<int> count{0};
std::mutex stdout_mu;
if (!d.start(port, [&](std::string json) {
std::lock_guard<std::mutex> lk(stdout_mu);
std::printf("[ssdp] %s\n", json.c_str());
++count;
}))
{
std::fprintf(stderr, "ssdp start on :%d failed\n", port);
return 1;
}
std::this_thread::sleep_for(std::chrono::seconds(seconds));
d.stop();
std::printf("received %d printer advertisements in %ds on :%d\n",
count.load(), seconds, port);
return count.load() > 0 ? 0 : 2;
}