mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-15 23:05:33 +00:00
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three commits are preserved, two of them cherry-picked with authorship intact; the sweep itself had to be regenerated. Opened as a new PR rather than force-pushing their branch. Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2 landed as #1937. ## Why regenerated rather than merged The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on current master is cheaper and less error-prone than resolving 72 conflicts that are all whitespace. The drift it fixes also grew in the meantime: 66 files now, against 72 then, but spread differently. ## The three commits 1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files. 2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet` copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range variable copied a `Config` embedding `sync.Once`. Cherry-picked unchanged. 3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one change, noted in the commit message: the ignore file pointed at `04bc80ee`, the sweep commit on their branch, which does not exist on this base and would make `git blame --ignore-revs-file` error. Repointed at `d3a02599`, the sweep here. ## Verification The claim "formatting only" is checked twice rather than asserted: - Every changed file is byte-identical to `gofmt(previous content)`. 0 of 66 deviate. - With line comments and all whitespace stripped, 0 of 66 files differ, so no code outside comments changed. 14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt` re-indents indented comment blocks to tabs and inserts a blank comment line before them; the behavior matrix above `resolveHopWithContext` in `cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own output, not an edit, but it is worth naming because it makes the diff look larger than "whitespace" suggests. The gate was run locally exactly as the workflow runs it: `gofmt` clean, and `go vet` clean in all 14 modules, including `cmd/ingestor` which is what commit 2 fixes. Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s), `cmd/ingestor` passes except `TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically on bare master with "A required privilege is not held by the client" (Windows symlink privilege on my host, not code). ## Sequencing This should go last in the queue. The sweep touches 66 files, so merging it before the remaining open Go PRs gives each of them a conflict about nothing but formatting. After it lands the gate is active, and any PR with drift fails CI until it runs `gofmt -w`. Excluded from the sweep: the misnamed `Dockerfile.go`, which is a Dockerfile that gofmt cannot parse (the workflow excludes it too), and `docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a spurious modification against `docs/deployment.md` and is unrelated. --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2120 lines
64 KiB
Go
2120 lines
64 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/ed25519"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/meshcore-analyzer/packetpath"
|
|
"github.com/meshcore-analyzer/sigvalidate"
|
|
)
|
|
|
|
func TestDecodeHeaderRoutTypes(t *testing.T) {
|
|
tests := []struct {
|
|
b byte
|
|
rt int
|
|
name string
|
|
}{
|
|
{0x00, 0, "TRANSPORT_FLOOD"},
|
|
{0x01, 1, "FLOOD"},
|
|
{0x02, 2, "DIRECT"},
|
|
{0x03, 3, "TRANSPORT_DIRECT"},
|
|
}
|
|
for _, tt := range tests {
|
|
h := decodeHeader(tt.b)
|
|
if h.RouteType != tt.rt {
|
|
t.Errorf("header 0x%02X: routeType=%d, want %d", tt.b, h.RouteType, tt.rt)
|
|
}
|
|
if h.RouteTypeName != tt.name {
|
|
t.Errorf("header 0x%02X: routeTypeName=%s, want %s", tt.b, h.RouteTypeName, tt.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodeHeaderPayloadTypes(t *testing.T) {
|
|
// 0x11 = 0b00_0100_01 → routeType=1(FLOOD), payloadType=4(ADVERT), version=0
|
|
h := decodeHeader(0x11)
|
|
if h.RouteType != 1 {
|
|
t.Errorf("0x11: routeType=%d, want 1", h.RouteType)
|
|
}
|
|
if h.PayloadType != 4 {
|
|
t.Errorf("0x11: payloadType=%d, want 4", h.PayloadType)
|
|
}
|
|
if h.PayloadVersion != 0 {
|
|
t.Errorf("0x11: payloadVersion=%d, want 0", h.PayloadVersion)
|
|
}
|
|
if h.RouteTypeName != "FLOOD" {
|
|
t.Errorf("0x11: routeTypeName=%s, want FLOOD", h.RouteTypeName)
|
|
}
|
|
if h.PayloadTypeName != "ADVERT" {
|
|
t.Errorf("0x11: payloadTypeName=%s, want ADVERT", h.PayloadTypeName)
|
|
}
|
|
}
|
|
|
|
func TestDecodePathZeroHops(t *testing.T) {
|
|
// 0x00: 0 hops, 1-byte hashes
|
|
pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Path.HashCount != 0 {
|
|
t.Errorf("hashCount=%d, want 0", pkt.Path.HashCount)
|
|
}
|
|
if pkt.Path.HashSize != 1 {
|
|
t.Errorf("hashSize=%d, want 1", pkt.Path.HashSize)
|
|
}
|
|
if len(pkt.Path.Hops) != 0 {
|
|
t.Errorf("hops=%d, want 0", len(pkt.Path.Hops))
|
|
}
|
|
}
|
|
|
|
func TestDecodePath1ByteHashes(t *testing.T) {
|
|
// 0x05: 5 hops, 1-byte hashes → 5 path bytes
|
|
pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Path.HashCount != 5 {
|
|
t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount)
|
|
}
|
|
if pkt.Path.HashSize != 1 {
|
|
t.Errorf("hashSize=%d, want 1", pkt.Path.HashSize)
|
|
}
|
|
if len(pkt.Path.Hops) != 5 {
|
|
t.Fatalf("hops=%d, want 5", len(pkt.Path.Hops))
|
|
}
|
|
if pkt.Path.Hops[0] != "AA" {
|
|
t.Errorf("hop[0]=%s, want AA", pkt.Path.Hops[0])
|
|
}
|
|
if pkt.Path.Hops[4] != "EE" {
|
|
t.Errorf("hop[4]=%s, want EE", pkt.Path.Hops[4])
|
|
}
|
|
}
|
|
|
|
func TestDecodePath2ByteHashes(t *testing.T) {
|
|
// 0x45: 5 hops, 2-byte hashes
|
|
pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Path.HashCount != 5 {
|
|
t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount)
|
|
}
|
|
if pkt.Path.HashSize != 2 {
|
|
t.Errorf("hashSize=%d, want 2", pkt.Path.HashSize)
|
|
}
|
|
if pkt.Path.Hops[0] != "AA11" {
|
|
t.Errorf("hop[0]=%s, want AA11", pkt.Path.Hops[0])
|
|
}
|
|
}
|
|
|
|
func TestDecodePath3ByteHashes(t *testing.T) {
|
|
// 0x8A: 10 hops, 3-byte hashes
|
|
pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Path.HashCount != 10 {
|
|
t.Errorf("hashCount=%d, want 10", pkt.Path.HashCount)
|
|
}
|
|
if pkt.Path.HashSize != 3 {
|
|
t.Errorf("hashSize=%d, want 3", pkt.Path.HashSize)
|
|
}
|
|
if len(pkt.Path.Hops) != 10 {
|
|
t.Errorf("hops=%d, want 10", len(pkt.Path.Hops))
|
|
}
|
|
}
|
|
|
|
func TestTransportCodes(t *testing.T) {
|
|
// Route type 0 (TRANSPORT_FLOOD) should have transport codes
|
|
// Firmware order: header + transport_codes(4) + path_len + path + payload
|
|
hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10)
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Header.RouteType != 0 {
|
|
t.Errorf("routeType=%d, want 0", pkt.Header.RouteType)
|
|
}
|
|
if pkt.TransportCodes == nil {
|
|
t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD")
|
|
}
|
|
if pkt.TransportCodes.Code1 != "AABB" {
|
|
t.Errorf("code1=%s, want AABB", pkt.TransportCodes.Code1)
|
|
}
|
|
if pkt.TransportCodes.Code2 != "CCDD" {
|
|
t.Errorf("code2=%s, want CCDD", pkt.TransportCodes.Code2)
|
|
}
|
|
|
|
// Route type 1 (FLOOD) should NOT have transport codes
|
|
pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt2.TransportCodes != nil {
|
|
t.Error("FLOOD should not have transport codes")
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertFull(t *testing.T) {
|
|
pubkey := strings.Repeat("AA", 32)
|
|
timestamp := "78563412" // 0x12345678 LE
|
|
signature := strings.Repeat("BB", 64)
|
|
// flags: 0x92 = repeater(2) | hasLocation(0x10) | hasName(0x80)
|
|
flags := "92"
|
|
lat := "40933402" // ~37.0
|
|
lon := "E0E6B8F8" // ~-122.1
|
|
name := "546573744E6F6465" // "TestNode"
|
|
|
|
hex := "1200" + pubkey + timestamp + signature + flags + lat + lon + name
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if pkt.Payload.Type != "ADVERT" {
|
|
t.Errorf("type=%s, want ADVERT", pkt.Payload.Type)
|
|
}
|
|
if pkt.Payload.PubKey != strings.ToLower(pubkey) {
|
|
t.Errorf("pubkey mismatch")
|
|
}
|
|
if pkt.Payload.Timestamp != 0x12345678 {
|
|
t.Errorf("timestamp=%d, want %d", pkt.Payload.Timestamp, 0x12345678)
|
|
}
|
|
|
|
if pkt.Payload.Flags == nil {
|
|
t.Fatal("flags should not be nil")
|
|
}
|
|
if pkt.Payload.Flags.Raw != 0x92 {
|
|
t.Errorf("flags.raw=%d, want 0x92", pkt.Payload.Flags.Raw)
|
|
}
|
|
if pkt.Payload.Flags.Type != 2 {
|
|
t.Errorf("flags.type=%d, want 2", pkt.Payload.Flags.Type)
|
|
}
|
|
if !pkt.Payload.Flags.Repeater {
|
|
t.Error("flags.repeater should be true")
|
|
}
|
|
if pkt.Payload.Flags.Room {
|
|
t.Error("flags.room should be false")
|
|
}
|
|
if !pkt.Payload.Flags.HasLocation {
|
|
t.Error("flags.hasLocation should be true")
|
|
}
|
|
if !pkt.Payload.Flags.HasName {
|
|
t.Error("flags.hasName should be true")
|
|
}
|
|
|
|
if pkt.Payload.Lat == nil {
|
|
t.Fatal("lat should not be nil")
|
|
}
|
|
if math.Abs(*pkt.Payload.Lat-37.0) > 0.001 {
|
|
t.Errorf("lat=%f, want ~37.0", *pkt.Payload.Lat)
|
|
}
|
|
if pkt.Payload.Lon == nil {
|
|
t.Fatal("lon should not be nil")
|
|
}
|
|
if math.Abs(*pkt.Payload.Lon-(-122.1)) > 0.001 {
|
|
t.Errorf("lon=%f, want ~-122.1", *pkt.Payload.Lon)
|
|
}
|
|
if pkt.Payload.Name != "TestNode" {
|
|
t.Errorf("name=%s, want TestNode", pkt.Payload.Name)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertTypeEnums(t *testing.T) {
|
|
makeAdvert := func(flagsByte byte) *DecodedPacket {
|
|
hex := "1200" + strings.Repeat("AA", 32) + "00000000" + strings.Repeat("BB", 64) +
|
|
strings.ToUpper(string([]byte{hexDigit(flagsByte >> 4), hexDigit(flagsByte & 0x0f)}))
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pkt
|
|
}
|
|
|
|
// type 1 = chat/companion
|
|
p1 := makeAdvert(0x01)
|
|
if p1.Payload.Flags.Type != 1 {
|
|
t.Errorf("type 1: flags.type=%d", p1.Payload.Flags.Type)
|
|
}
|
|
if !p1.Payload.Flags.Chat {
|
|
t.Error("type 1: chat should be true")
|
|
}
|
|
|
|
// type 2 = repeater
|
|
p2 := makeAdvert(0x02)
|
|
if !p2.Payload.Flags.Repeater {
|
|
t.Error("type 2: repeater should be true")
|
|
}
|
|
|
|
// type 3 = room
|
|
p3 := makeAdvert(0x03)
|
|
if !p3.Payload.Flags.Room {
|
|
t.Error("type 3: room should be true")
|
|
}
|
|
|
|
// type 4 = sensor
|
|
p4 := makeAdvert(0x04)
|
|
if !p4.Payload.Flags.Sensor {
|
|
t.Error("type 4: sensor should be true")
|
|
}
|
|
}
|
|
|
|
func hexDigit(v byte) byte {
|
|
v = v & 0x0f
|
|
if v < 10 {
|
|
return '0' + v
|
|
}
|
|
return 'a' + v - 10
|
|
}
|
|
|
|
func TestDecodeAdvertNoLocationNoName(t *testing.T) {
|
|
hex := "1200" + strings.Repeat("CC", 32) + "00000000" + strings.Repeat("DD", 64) + "02"
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Payload.Flags.HasLocation {
|
|
t.Error("hasLocation should be false")
|
|
}
|
|
if pkt.Payload.Flags.HasName {
|
|
t.Error("hasName should be false")
|
|
}
|
|
if pkt.Payload.Lat != nil {
|
|
t.Error("lat should be nil")
|
|
}
|
|
if pkt.Payload.Name != "" {
|
|
t.Errorf("name should be empty, got %s", pkt.Payload.Name)
|
|
}
|
|
}
|
|
|
|
func TestGoldenFixtureTxtMsg(t *testing.T) {
|
|
pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Header.PayloadType != PayloadTXT_MSG {
|
|
t.Errorf("payloadType=%d, want %d", pkt.Header.PayloadType, PayloadTXT_MSG)
|
|
}
|
|
if pkt.Header.RouteType != RouteDirect {
|
|
t.Errorf("routeType=%d, want %d", pkt.Header.RouteType, RouteDirect)
|
|
}
|
|
if pkt.Path.HashCount != 0 {
|
|
t.Errorf("hashCount=%d, want 0", pkt.Path.HashCount)
|
|
}
|
|
if pkt.Payload.DestHash != "d6" {
|
|
t.Errorf("destHash=%s, want d6", pkt.Payload.DestHash)
|
|
}
|
|
if pkt.Payload.SrcHash != "9f" {
|
|
t.Errorf("srcHash=%s, want 9f", pkt.Payload.SrcHash)
|
|
}
|
|
}
|
|
|
|
func TestGoldenFixtureAdvert(t *testing.T) {
|
|
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
|
pkt, err := DecodePacket(rawHex, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Payload.Type != "ADVERT" {
|
|
t.Errorf("type=%s, want ADVERT", pkt.Payload.Type)
|
|
}
|
|
if pkt.Payload.PubKey != "46d62de27d4c5194d7821fc5a34a45565dcc2537b300b9ab6275255cefb65d84" {
|
|
t.Errorf("pubKey mismatch: %s", pkt.Payload.PubKey)
|
|
}
|
|
if pkt.Payload.Flags == nil || !pkt.Payload.Flags.Repeater {
|
|
t.Error("should be repeater")
|
|
}
|
|
if math.Abs(*pkt.Payload.Lat-37.0) > 0.001 {
|
|
t.Errorf("lat=%f, want ~37.0", *pkt.Payload.Lat)
|
|
}
|
|
if pkt.Payload.Name != "MRR2-R" {
|
|
t.Errorf("name=%s, want MRR2-R", pkt.Payload.Name)
|
|
}
|
|
}
|
|
|
|
func TestGoldenFixtureUnicodeAdvert(t *testing.T) {
|
|
rawHex := "120073CFF971E1CB5754A742C152B2D2E0EB108A19B246D663ED8898A72C4A5AD86EA6768E66694B025EDF6939D5C44CFF719C5D5520E5F06B20680A83AD9C2C61C3227BBB977A85EE462F3553445FECF8EDD05C234ECE217272E503F14D6DF2B1B9B133890C923CDF3002F8FDC1F85045414BF09F8CB3"
|
|
pkt, err := DecodePacket(rawHex, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Payload.Type != "ADVERT" {
|
|
t.Errorf("type=%s, want ADVERT", pkt.Payload.Type)
|
|
}
|
|
if !pkt.Payload.Flags.Repeater {
|
|
t.Error("should be repeater")
|
|
}
|
|
// Name contains emoji: PEAK🌳
|
|
if !strings.HasPrefix(pkt.Payload.Name, "PEAK") {
|
|
t.Errorf("name=%s, expected to start with PEAK", pkt.Payload.Name)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacketTooShort(t *testing.T) {
|
|
_, err := DecodePacket("FF", nil, false)
|
|
if err == nil {
|
|
t.Error("expected error for 1-byte packet")
|
|
}
|
|
}
|
|
|
|
func TestDecodePacketInvalidHex(t *testing.T) {
|
|
_, err := DecodePacket("ZZZZ", nil, false)
|
|
if err == nil {
|
|
t.Error("expected error for invalid hex")
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHash(t *testing.T) {
|
|
hash := ComputeContentHash("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976")
|
|
if len(hash) != 16 {
|
|
t.Errorf("hash length=%d, want 16", len(hash))
|
|
}
|
|
// Same content with different path should produce same hash
|
|
// (path bytes are stripped, only header + payload hashed)
|
|
|
|
// Verify consistency
|
|
hash2 := ComputeContentHash("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976")
|
|
if hash != hash2 {
|
|
t.Error("content hash not deterministic")
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvert(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
|
|
// Good advert
|
|
good := &Payload{PubKey: goodPk, Flags: &AdvertFlags{Repeater: true}}
|
|
ok, _ := ValidateAdvert(good)
|
|
if !ok {
|
|
t.Error("good advert should validate")
|
|
}
|
|
|
|
// Nil
|
|
ok, _ = ValidateAdvert(nil)
|
|
if ok {
|
|
t.Error("nil should fail")
|
|
}
|
|
|
|
// Error payload
|
|
ok, _ = ValidateAdvert(&Payload{Error: "bad"})
|
|
if ok {
|
|
t.Error("error payload should fail")
|
|
}
|
|
|
|
// Short pubkey
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: "aa"})
|
|
if ok {
|
|
t.Error("short pubkey should fail")
|
|
}
|
|
|
|
// All-zero pubkey
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: strings.Repeat("0", 64)})
|
|
if ok {
|
|
t.Error("all-zero pubkey should fail")
|
|
}
|
|
|
|
// Invalid lat
|
|
badLat := 999.0
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &badLat})
|
|
if ok {
|
|
t.Error("invalid lat should fail")
|
|
}
|
|
|
|
// Invalid lon
|
|
badLon := -999.0
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lon: &badLon})
|
|
if ok {
|
|
t.Error("invalid lon should fail")
|
|
}
|
|
|
|
// Control chars in name
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Name: "test\x00name"})
|
|
if ok {
|
|
t.Error("control chars in name should fail")
|
|
}
|
|
|
|
// Name too long
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Name: strings.Repeat("x", 65)})
|
|
if ok {
|
|
t.Error("long name should fail")
|
|
}
|
|
}
|
|
|
|
func TestDecodePacketPayloadRaw(t *testing.T) {
|
|
// Build a minimal TRANSPORT_FLOOD packet (route_type=0):
|
|
// header(1) + transport_codes(4) + path_len(1) + payload(N)
|
|
// Header 0x00 = route_type=TRANSPORT_FLOOD, payload_type=0, version=0
|
|
// Code1=9A52, Code2=0000, path_len=0x00 (0 hops, hash_size=1)
|
|
payload := []byte("hello")
|
|
raw := []byte{0x00, 0x9A, 0x52, 0x00, 0x00, 0x00}
|
|
raw = append(raw, payload...)
|
|
hexStr := strings.ToUpper(hex.EncodeToString(raw))
|
|
|
|
decoded, err := DecodePacket(hexStr, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket: %v", err)
|
|
}
|
|
if decoded.TransportCodes == nil {
|
|
t.Fatal("expected TransportCodes, got nil")
|
|
}
|
|
if string(decoded.payloadRaw) != string(payload) {
|
|
t.Errorf("payloadRaw = %v, want %v", decoded.payloadRaw, payload)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtShort(t *testing.T) {
|
|
p := decodeGrpTxt([]byte{0x01, 0x02}, nil)
|
|
if p.Error != "too short" {
|
|
t.Errorf("expected 'too short' error, got %q", p.Error)
|
|
}
|
|
if p.Type != "GRP_TXT" {
|
|
t.Errorf("type=%s, want GRP_TXT", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtValid(t *testing.T) {
|
|
p := decodeGrpTxt([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE}, nil)
|
|
if p.Error != "" {
|
|
t.Errorf("unexpected error: %s", p.Error)
|
|
}
|
|
if p.ChannelHash != 0xAA {
|
|
t.Errorf("channelHash=%d, want 0xAA", p.ChannelHash)
|
|
}
|
|
if p.MAC != "bbcc" {
|
|
t.Errorf("mac=%s, want bbcc", p.MAC)
|
|
}
|
|
if p.EncryptedData != "ddee" {
|
|
t.Errorf("encryptedData=%s, want ddee", p.EncryptedData)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAnonReqShort(t *testing.T) {
|
|
p := decodeAnonReq(make([]byte, 10))
|
|
if p.Error != "too short" {
|
|
t.Errorf("expected 'too short' error, got %q", p.Error)
|
|
}
|
|
if p.Type != "ANON_REQ" {
|
|
t.Errorf("type=%s, want ANON_REQ", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAnonReqValid(t *testing.T) {
|
|
buf := make([]byte, 40)
|
|
buf[0] = 0xFF // destHash
|
|
for i := 1; i < 33; i++ {
|
|
buf[i] = byte(i)
|
|
}
|
|
buf[33] = 0xAA
|
|
buf[34] = 0xBB
|
|
p := decodeAnonReq(buf)
|
|
if p.Error != "" {
|
|
t.Errorf("unexpected error: %s", p.Error)
|
|
}
|
|
if p.DestHash != "ff" {
|
|
t.Errorf("destHash=%s, want ff", p.DestHash)
|
|
}
|
|
if p.MAC != "aabb" {
|
|
t.Errorf("mac=%s, want aabb", p.MAC)
|
|
}
|
|
// #1864: ANON_REQ must capture the FULL 32-byte source pubkey (buf[1:33]),
|
|
// not a 1-byte srcHash. buf[i]=i for i in 1..32 -> 0102..20.
|
|
wantPK := "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
|
if p.SrcPubKey != wantPK {
|
|
t.Errorf("srcPubKey=%s, want %s", p.SrcPubKey, wantPK)
|
|
}
|
|
}
|
|
|
|
func TestDecodePathPayloadShort(t *testing.T) {
|
|
p := decodePathPayload([]byte{0x01, 0x02, 0x03})
|
|
if p.Error != "too short" {
|
|
t.Errorf("expected 'too short' error, got %q", p.Error)
|
|
}
|
|
if p.Type != "PATH" {
|
|
t.Errorf("type=%s, want PATH", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodePathPayloadValid(t *testing.T) {
|
|
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
|
p := decodePathPayload(buf)
|
|
if p.Error != "" {
|
|
t.Errorf("unexpected error: %s", p.Error)
|
|
}
|
|
if p.DestHash != "aa" {
|
|
t.Errorf("destHash=%s, want aa", p.DestHash)
|
|
}
|
|
if p.SrcHash != "bb" {
|
|
t.Errorf("srcHash=%s, want bb", p.SrcHash)
|
|
}
|
|
if p.PathData != "eeff" {
|
|
t.Errorf("pathData=%s, want eeff", p.PathData)
|
|
}
|
|
}
|
|
|
|
func TestDecodeTraceShort(t *testing.T) {
|
|
p := decodeTrace(make([]byte, 5))
|
|
if p.Error != "too short" {
|
|
t.Errorf("expected 'too short' error, got %q", p.Error)
|
|
}
|
|
if p.Type != "TRACE" {
|
|
t.Errorf("type=%s, want TRACE", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodeTraceValid(t *testing.T) {
|
|
buf := make([]byte, 16)
|
|
// tag(4) + authCode(4) + flags(1) + pathData
|
|
binary.LittleEndian.PutUint32(buf[0:4], 1) // tag = 1
|
|
binary.LittleEndian.PutUint32(buf[4:8], 0xDEADBEEF) // authCode
|
|
buf[8] = 0x02 // flags
|
|
buf[9] = 0xAA // path data
|
|
p := decodeTrace(buf)
|
|
if p.Error != "" {
|
|
t.Errorf("unexpected error: %s", p.Error)
|
|
}
|
|
if p.Tag != 1 {
|
|
t.Errorf("tag=%d, want 1", p.Tag)
|
|
}
|
|
if p.AuthCode != 0xDEADBEEF {
|
|
t.Errorf("authCode=%d, want 0xDEADBEEF", p.AuthCode)
|
|
}
|
|
if p.TraceFlags == nil || *p.TraceFlags != 2 {
|
|
t.Errorf("traceFlags=%v, want 2", p.TraceFlags)
|
|
}
|
|
if p.Type != "TRACE" {
|
|
t.Errorf("type=%s, want TRACE", p.Type)
|
|
}
|
|
if p.PathData == "" {
|
|
t.Error("pathData should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestDecodeTracePathParsing(t *testing.T) {
|
|
// Packet from issue #276: 260001807dca00000000007d547d
|
|
// Path byte 0x00 → hashSize=1, hops in payload at buf[9:] = 7d 54 7d
|
|
// Expected path: ["7D", "54", "7D"]
|
|
pkt, err := DecodePacket("260001807dca00000000007d547d", nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.Payload.Type != "TRACE" {
|
|
t.Errorf("payload type=%s, want TRACE", pkt.Payload.Type)
|
|
}
|
|
want := []string{"7D", "54", "7D"}
|
|
if len(pkt.Path.Hops) != len(want) {
|
|
t.Fatalf("hops=%v, want %v", pkt.Path.Hops, want)
|
|
}
|
|
for i, h := range want {
|
|
if pkt.Path.Hops[i] != h {
|
|
t.Errorf("hops[%d]=%s, want %s", i, pkt.Path.Hops[i], h)
|
|
}
|
|
}
|
|
if pkt.Path.HashCount != 3 {
|
|
t.Errorf("hashCount=%d, want 3", pkt.Path.HashCount)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertShort(t *testing.T) {
|
|
p := decodeAdvert(make([]byte, 50), false)
|
|
if p.Error != "too short for advert" {
|
|
t.Errorf("expected 'too short for advert' error, got %q", p.Error)
|
|
}
|
|
}
|
|
|
|
func TestDecodeEncryptedPayloadShort(t *testing.T) {
|
|
p := decodeEncryptedPayload("REQ", []byte{0x01, 0x02})
|
|
if p.Error != "too short" {
|
|
t.Errorf("expected 'too short' error, got %q", p.Error)
|
|
}
|
|
if p.Type != "REQ" {
|
|
t.Errorf("type=%s, want REQ", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodeEncryptedPayloadValid(t *testing.T) {
|
|
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
|
p := decodeEncryptedPayload("RESPONSE", buf)
|
|
if p.Error != "" {
|
|
t.Errorf("unexpected error: %s", p.Error)
|
|
}
|
|
if p.DestHash != "aa" {
|
|
t.Errorf("destHash=%s, want aa", p.DestHash)
|
|
}
|
|
if p.SrcHash != "bb" {
|
|
t.Errorf("srcHash=%s, want bb", p.SrcHash)
|
|
}
|
|
if p.MAC != "ccdd" {
|
|
t.Errorf("mac=%s, want ccdd", p.MAC)
|
|
}
|
|
if p.EncryptedData != "eeff" {
|
|
t.Errorf("encryptedData=%s, want eeff", p.EncryptedData)
|
|
}
|
|
}
|
|
|
|
func TestDecodePayloadGRPData(t *testing.T) {
|
|
// GRP_DATA (0x06) decoder added for #1279 P0 #1 — envelope only when no
|
|
// channel key matches (firmware/src/helpers/BaseChatMesh.cpp:500).
|
|
buf := []byte{0x01, 0x02, 0x03}
|
|
p := decodePayload(PayloadGRP_DATA, buf, nil, false)
|
|
if p.Type != "GRP_DATA" {
|
|
t.Errorf("type=%s, want GRP_DATA", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodePayloadRAWCustom(t *testing.T) {
|
|
// #1279 P2 #5: RAW_CUSTOM (0x0F) now exposes envelope shape (length +
|
|
// first-byte tag) per firmware/src/Mesh.cpp:577 (createRawData).
|
|
buf := []byte{0xFF, 0xFE}
|
|
p := decodePayload(PayloadRAW_CUSTOM, buf, nil, false)
|
|
if p.Type != "RAW_CUSTOM" {
|
|
t.Errorf("type=%s, want RAW_CUSTOM", p.Type)
|
|
}
|
|
if p.RawLength == nil || *p.RawLength != 2 {
|
|
t.Errorf("rawLength missing or wrong, want 2")
|
|
}
|
|
if p.FirstByteTag != "FF" {
|
|
t.Errorf("firstByteTag=%q, want FF", p.FirstByteTag)
|
|
}
|
|
}
|
|
|
|
func TestDecodePayloadAllTypes(t *testing.T) {
|
|
// REQ
|
|
p := decodePayload(PayloadREQ, make([]byte, 10), nil, false)
|
|
if p.Type != "REQ" {
|
|
t.Errorf("REQ: type=%s", p.Type)
|
|
}
|
|
|
|
// RESPONSE
|
|
p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil, false)
|
|
if p.Type != "RESPONSE" {
|
|
t.Errorf("RESPONSE: type=%s", p.Type)
|
|
}
|
|
|
|
// TXT_MSG
|
|
p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil, false)
|
|
if p.Type != "TXT_MSG" {
|
|
t.Errorf("TXT_MSG: type=%s", p.Type)
|
|
}
|
|
|
|
// ACK
|
|
p = decodePayload(PayloadACK, make([]byte, 10), nil, false)
|
|
if p.Type != "ACK" {
|
|
t.Errorf("ACK: type=%s", p.Type)
|
|
}
|
|
|
|
// GRP_TXT
|
|
p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil, false)
|
|
if p.Type != "GRP_TXT" {
|
|
t.Errorf("GRP_TXT: type=%s", p.Type)
|
|
}
|
|
|
|
// ANON_REQ
|
|
p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil, false)
|
|
if p.Type != "ANON_REQ" {
|
|
t.Errorf("ANON_REQ: type=%s", p.Type)
|
|
}
|
|
|
|
// PATH
|
|
p = decodePayload(PayloadPATH, make([]byte, 10), nil, false)
|
|
if p.Type != "PATH" {
|
|
t.Errorf("PATH: type=%s", p.Type)
|
|
}
|
|
|
|
// TRACE
|
|
p = decodePayload(PayloadTRACE, make([]byte, 20), nil, false)
|
|
if p.Type != "TRACE" {
|
|
t.Errorf("TRACE: type=%s", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestPayloadJSON(t *testing.T) {
|
|
p := &Payload{Type: "TEST", Name: "hello"}
|
|
j := PayloadJSON(p)
|
|
if j == "" || j == "{}" {
|
|
t.Errorf("PayloadJSON returned empty: %s", j)
|
|
}
|
|
if !strings.Contains(j, `"type":"TEST"`) {
|
|
t.Errorf("PayloadJSON missing type: %s", j)
|
|
}
|
|
if !strings.Contains(j, `"name":"hello"`) {
|
|
t.Errorf("PayloadJSON missing name: %s", j)
|
|
}
|
|
}
|
|
|
|
func TestPayloadJSONNil(t *testing.T) {
|
|
// nil should not panic
|
|
j := PayloadJSON(nil)
|
|
if j != "null" && j != "{}" {
|
|
// json.Marshal(nil) returns "null"
|
|
t.Logf("PayloadJSON(nil) = %s", j)
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertNaNLat(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
nanVal := math.NaN()
|
|
ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &nanVal})
|
|
if ok {
|
|
t.Error("NaN lat should fail")
|
|
}
|
|
if !strings.Contains(reason, "lat") {
|
|
t.Errorf("reason should mention lat: %s", reason)
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertInfLon(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
infVal := math.Inf(1)
|
|
ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &infVal})
|
|
if ok {
|
|
t.Error("Inf lon should fail")
|
|
}
|
|
if !strings.Contains(reason, "lon") {
|
|
t.Errorf("reason should mention lon: %s", reason)
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertNegInfLat(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
negInf := math.Inf(-1)
|
|
ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &negInf})
|
|
if ok {
|
|
t.Error("-Inf lat should fail")
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertNaNLon(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
nan := math.NaN()
|
|
ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &nan})
|
|
if ok {
|
|
t.Error("NaN lon should fail")
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertControlChars(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
tests := []struct {
|
|
name string
|
|
char string
|
|
}{
|
|
{"null", "\x00"},
|
|
{"bell", "\x07"},
|
|
{"backspace", "\x08"},
|
|
{"vtab", "\x0b"},
|
|
{"formfeed", "\x0c"},
|
|
{"shift out", "\x0e"},
|
|
{"unit sep", "\x1f"},
|
|
{"delete", "\x7f"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Name: "test" + tt.char + "name"})
|
|
if ok {
|
|
t.Errorf("control char %q in name should fail", tt.char)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertAllowedCharsInName(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
// Tab (\t = 0x09), newline (\n = 0x0a), carriage return (\r = 0x0d) are NOT blocked
|
|
ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Name: "hello\tworld", Flags: &AdvertFlags{Repeater: true}})
|
|
if !ok {
|
|
t.Errorf("tab in name should be allowed, got reason: %s", reason)
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertUnknownRole(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
// type=0 maps to companion via Chat=false, Repeater=false, Room=false, Sensor=false → companion
|
|
// type=5 (unknown) → companion (default), which IS a valid role
|
|
// But if all booleans are false AND type is 0, advertRole returns "companion" which is valid
|
|
// To get "unknown", we'd need a flags combo that doesn't match any valid role
|
|
// Actually advertRole always returns companion as default — so let's just test the validation path
|
|
flags := &AdvertFlags{Type: 5, Chat: false, Repeater: false, Room: false, Sensor: false}
|
|
ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Flags: flags})
|
|
// advertRole returns "companion" for this, which is valid
|
|
if !ok {
|
|
t.Errorf("default companion role should be valid, got: %s", reason)
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertValidLocation(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
lat := 45.0
|
|
lon := -90.0
|
|
ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat, Lon: &lon, Flags: &AdvertFlags{Repeater: true}})
|
|
if !ok {
|
|
t.Error("valid lat/lon should pass")
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertBoundaryLat(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
// Exactly at boundary
|
|
lat90 := 90.0
|
|
ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat90})
|
|
if !ok {
|
|
t.Error("lat=90 should pass")
|
|
}
|
|
latNeg90 := -90.0
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &latNeg90})
|
|
if !ok {
|
|
t.Error("lat=-90 should pass")
|
|
}
|
|
// Just over
|
|
lat91 := 90.001
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat91})
|
|
if ok {
|
|
t.Error("lat=90.001 should fail")
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertBoundaryLon(t *testing.T) {
|
|
goodPk := strings.Repeat("aa", 32)
|
|
lon180 := 180.0
|
|
ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lon180})
|
|
if !ok {
|
|
t.Error("lon=180 should pass")
|
|
}
|
|
lonNeg180 := -180.0
|
|
ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lonNeg180})
|
|
if !ok {
|
|
t.Error("lon=-180 should pass")
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHashShortHex(t *testing.T) {
|
|
// Less than 16 hex chars and invalid hex
|
|
hash := ComputeContentHash("AB")
|
|
if hash != "AB" {
|
|
t.Errorf("short hex hash=%s, want AB", hash)
|
|
}
|
|
|
|
// Exactly 16 chars invalid hex
|
|
hash = ComputeContentHash("ZZZZZZZZZZZZZZZZ")
|
|
if len(hash) != 16 {
|
|
t.Errorf("invalid hex hash length=%d, want 16", len(hash))
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHashTransportRoute(t *testing.T) {
|
|
// Route type 0 (TRANSPORT_FLOOD) with transport codes then path=0x00 (0 hops)
|
|
// header=0x14 (TRANSPORT_FLOOD, ADVERT), transport(4), path=0x00
|
|
hex := "14" + "AABBCCDD" + "00" + strings.Repeat("EE", 10)
|
|
hash := ComputeContentHash(hex)
|
|
if len(hash) != 16 {
|
|
t.Errorf("hash length=%d, want 16", len(hash))
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHashPayloadBeyondBuffer(t *testing.T) {
|
|
// path claims more bytes than buffer has → fallback
|
|
// header=0x05 (FLOOD, REQ), pathByte=0x3F (63 hops of 1 byte = 63 path bytes)
|
|
// but total buffer is only 4 bytes
|
|
hex := "053F" + "AABB"
|
|
hash := ComputeContentHash(hex)
|
|
// payloadStart = 2 + 63 = 65, but buffer is only 4 bytes
|
|
// Should fallback — rawHex is 8 chars (< 16), so returns rawHex
|
|
if hash != hex {
|
|
t.Errorf("hash=%s, want %s", hash, hex)
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) {
|
|
// Same as above but with rawHex >= 16 chars → returns first 16
|
|
hex := "053F" + strings.Repeat("AA", 20) // 44 chars total, but pathByte claims 63 hops
|
|
hash := ComputeContentHash(hex)
|
|
if len(hash) != 16 {
|
|
t.Errorf("hash length=%d, want 16", len(hash))
|
|
}
|
|
if hash != hex[:16] {
|
|
t.Errorf("hash=%s, want %s", hash, hex[:16])
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHashTransportBeyondBuffer(t *testing.T) {
|
|
// Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes
|
|
// header=0x00, transport(4), pathByte=0x02 (2 hops, 1-byte hash)
|
|
// offset=1+4+1+2=8, buffer needs to be >= 8
|
|
hex := "00" + "AABB" + "CCDD" + "02" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
|
hash := ComputeContentHash(hex)
|
|
if len(hash) != 16 {
|
|
t.Errorf("hash length=%d, want 16", len(hash))
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHashLongFallback(t *testing.T) {
|
|
// Long rawHex (>= 16) but invalid → returns first 16 chars
|
|
longInvalid := "ZZZZZZZZZZZZZZZZZZZZZZZZ"
|
|
hash := ComputeContentHash(longInvalid)
|
|
if hash != longInvalid[:16] {
|
|
t.Errorf("hash=%s, want first 16 of input", hash)
|
|
}
|
|
}
|
|
|
|
// TestComputeContentHashRouteTypeIndependence verifies that the same logical
|
|
// packet produces the same content hash regardless of route type (issue #786).
|
|
func TestComputeContentHashRouteTypeIndependence(t *testing.T) {
|
|
// Same payload type (TXT_MSG=2, bits 2-5) with different route types.
|
|
// Header 0x08 = route_type 0 (TRANSPORT_FLOOD), payload_type 2
|
|
// Header 0x0A = route_type 2 (DIRECT), payload_type 2
|
|
// Header 0x09 = route_type 1 (FLOOD), payload_type 2
|
|
// pathByte=0x00, payload=D69FD7A5A7
|
|
payloadHex := "D69FD7A5A7"
|
|
|
|
// FLOOD: header=0x09 (route_type 1), pathByte=0x00
|
|
floodHex := "09" + "00" + payloadHex
|
|
// DIRECT: header=0x0A (route_type 2), pathByte=0x00
|
|
directHex := "0A" + "00" + payloadHex
|
|
|
|
hashFlood := ComputeContentHash(floodHex)
|
|
hashDirect := ComputeContentHash(directHex)
|
|
if hashFlood != hashDirect {
|
|
t.Errorf("same payload with different route types produced different hashes: flood=%s direct=%s", hashFlood, hashDirect)
|
|
}
|
|
}
|
|
|
|
// TestComputeContentHashTraceIncludesPathLen verifies TRACE packets include
|
|
// path_len in the hash (matching firmware behavior).
|
|
func TestComputeContentHashTraceIncludesPathLen(t *testing.T) {
|
|
// TRACE = payload_type 0x09, so header bits 2-5 = 0x09 → header = 0x09<<2 | route=2 = 0x26
|
|
// pathByte=0x01 (1 hop, 1-byte hash) → 1 path byte
|
|
traceHeader1 := "26" // route=2, payload_type=9
|
|
pathByte1 := "01"
|
|
pathData1 := "AA"
|
|
payload := "DEADBEEF"
|
|
hex1 := traceHeader1 + pathByte1 + pathData1 + payload
|
|
|
|
// Same but pathByte=0x02 (2 hops) → 2 path bytes
|
|
pathByte2 := "02"
|
|
pathData2 := "AABB"
|
|
hex2 := traceHeader1 + pathByte2 + pathData2 + payload
|
|
|
|
hash1 := ComputeContentHash(hex1)
|
|
hash2 := ComputeContentHash(hex2)
|
|
if hash1 == hash2 {
|
|
t.Error("TRACE packets with different path_len should produce different hashes (path_len is part of hash input)")
|
|
}
|
|
}
|
|
|
|
// TestComputeContentHashMatchesFirmware verifies hash output matches what the
|
|
// firmware would compute: SHA256(payload_type_byte + payload)[:16hex].
|
|
func TestComputeContentHashMatchesFirmware(t *testing.T) {
|
|
// header=0x0A → payload_type = (0x0A >> 2) & 0x0F = 2
|
|
// pathByte=0x00, payload = D69FD7A5A7475DB07337749AE61FA53A4788E976
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
hash := ComputeContentHash(rawHex)
|
|
|
|
// Manually compute expected: SHA256(0x02 + payload_bytes)
|
|
payloadBytes, _ := hex.DecodeString("D69FD7A5A7475DB07337749AE61FA53A4788E976")
|
|
toHash := append([]byte{0x02}, payloadBytes...)
|
|
expected := sha256.Sum256(toHash)
|
|
expectedHex := hex.EncodeToString(expected[:])[:16]
|
|
if hash != expectedHex {
|
|
t.Errorf("hash=%s, want %s (firmware-compatible)", hash, expectedHex)
|
|
}
|
|
}
|
|
|
|
// TestComputeContentHashTraceGoldenValue is a golden-value test that locks down
|
|
// the 2-byte path_len (uint16 LE) behavior for TRACE hashing. If anyone removes
|
|
// the 0x00 byte from the hash input, this test breaks.
|
|
//
|
|
// Packet: header=0x25 (FLOOD route=1, payload_type=TRACE=0x09), pathByte=0x02
|
|
// (2 hops, 1-byte hash), path=[AA,BB], payload=[DE,AD,BE,EF].
|
|
// Hash input: [0x09, 0x02, 0x00, 0xDE, 0xAD, 0xBE, 0xEF]
|
|
//
|
|
// → SHA256 = b1baaf3bf0d0726c2672b1ec9e2665dc...
|
|
// → first 16 hex chars = "b1baaf3bf0d0726c"
|
|
func TestComputeContentHashTraceGoldenValue(t *testing.T) {
|
|
// TRACE packet: header byte 0x25 = payload_type 9 (TRACE), route_type 1 (FLOOD)
|
|
// pathByte 0x02 = hash_size 1, hash_count 2
|
|
// 2 path bytes (AA, BB), then payload DEADBEEF
|
|
rawHex := "2502AABBDEADBEEF"
|
|
hash := ComputeContentHash(rawHex)
|
|
|
|
// Pre-computed: SHA256(0x09 0x02 0x00 0xDE 0xAD 0xBE 0xEF)[:16hex]
|
|
// The 0x00 is the high byte of uint16_t path_len (little-endian).
|
|
const golden = "b1baaf3bf0d0726c"
|
|
if hash != golden {
|
|
t.Errorf("TRACE golden hash = %s, want %s (2-byte path_len encoding)", hash, golden)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacketWithWhitespace(t *testing.T) {
|
|
raw := "0A 00 D6 9F D7 A5 A7 47 5D B0 73 37 74 9A E6 1F A5 3A 47 88 E9 76"
|
|
pkt, err := DecodePacket(raw, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Header.PayloadType != PayloadTXT_MSG {
|
|
t.Errorf("payloadType=%d, want %d", pkt.Header.PayloadType, PayloadTXT_MSG)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacketWithNewlines(t *testing.T) {
|
|
raw := "0A00\nD69F\r\nD7A5A7475DB07337749AE61FA53A4788E976"
|
|
pkt, err := DecodePacket(raw, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Payload.Type != "TXT_MSG" {
|
|
t.Errorf("type=%s, want TXT_MSG", pkt.Payload.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacketTransportRouteTooShort(t *testing.T) {
|
|
// TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes
|
|
_, err := DecodePacket("1400", nil, false)
|
|
if err == nil {
|
|
t.Error("expected error for transport route with too-short buffer")
|
|
}
|
|
if !strings.Contains(err.Error(), "transport codes") {
|
|
t.Errorf("error should mention transport codes: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAckShort(t *testing.T) {
|
|
p := decodeAck([]byte{0x01, 0x02, 0x03})
|
|
if p.Error != "too short" {
|
|
t.Errorf("expected 'too short', got %q", p.Error)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAckValid(t *testing.T) {
|
|
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD}
|
|
p := decodeAck(buf)
|
|
if p.Error != "" {
|
|
t.Errorf("unexpected error: %s", p.Error)
|
|
}
|
|
if p.ExtraHash != "ddccbbaa" {
|
|
t.Errorf("extraHash=%s, want ddccbbaa", p.ExtraHash)
|
|
}
|
|
if p.DestHash != "" {
|
|
t.Errorf("destHash should be empty, got %s", p.DestHash)
|
|
}
|
|
if p.SrcHash != "" {
|
|
t.Errorf("srcHash should be empty, got %s", p.SrcHash)
|
|
}
|
|
}
|
|
|
|
func TestIsTransportRoute(t *testing.T) {
|
|
if !isTransportRoute(RouteTransportFlood) {
|
|
t.Error("RouteTransportFlood should be transport")
|
|
}
|
|
if !isTransportRoute(RouteTransportDirect) {
|
|
t.Error("RouteTransportDirect should be transport")
|
|
}
|
|
if isTransportRoute(RouteFlood) {
|
|
t.Error("RouteFlood should not be transport")
|
|
}
|
|
if isTransportRoute(RouteDirect) {
|
|
t.Error("RouteDirect should not be transport")
|
|
}
|
|
}
|
|
|
|
func TestDecodeHeaderUnknownTypes(t *testing.T) {
|
|
// Payload type that doesn't map to any known name
|
|
// bits 5-2 = 0x0C (12) is CONTROL but 0x0D (13) would be unknown
|
|
// byte = 0b00_1101_01 = 0x35 → routeType=1, payloadType=0x0D(13), version=0
|
|
h := decodeHeader(0x35)
|
|
if h.PayloadTypeName != "UNKNOWN" {
|
|
t.Errorf("payloadTypeName=%s, want UNKNOWN for type 13", h.PayloadTypeName)
|
|
}
|
|
}
|
|
|
|
func TestDecodePayloadMultipart(t *testing.T) {
|
|
// MULTIPART (0x0A) now decoded — #1279 P0 #2 (firmware/src/Mesh.cpp:289).
|
|
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil, false)
|
|
if p.Type != "MULTIPART" {
|
|
t.Errorf("MULTIPART type=%s, want MULTIPART", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodePayloadControl(t *testing.T) {
|
|
// CONTROL (0x0B) now decoded — #1279 P1 #4 (firmware/src/Mesh.cpp:69).
|
|
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil, false)
|
|
if p.Type != "CONTROL" {
|
|
t.Errorf("CONTROL type=%s, want CONTROL", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodePathTruncatedBuffer(t *testing.T) {
|
|
// path byte claims 5 hops of 2 bytes = 10 bytes, but only 4 available
|
|
path, consumed, _ := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0)
|
|
if path.HashCount != 5 {
|
|
t.Errorf("hashCount=%d, want 5", path.HashCount)
|
|
}
|
|
// Should only decode 2 hops (4 bytes / 2 bytes per hop)
|
|
if len(path.Hops) != 2 {
|
|
t.Errorf("hops=%d, want 2 (truncated)", len(path.Hops))
|
|
}
|
|
if consumed != 10 {
|
|
t.Errorf("consumed=%d, want 10 (full claimed size)", consumed)
|
|
}
|
|
}
|
|
|
|
func TestDecodeFloodAdvert5Hops(t *testing.T) {
|
|
// From test-decoder.js Test 1
|
|
raw := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172"
|
|
pkt, err := DecodePacket(raw, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Header.RouteTypeName != "FLOOD" {
|
|
t.Errorf("route=%s, want FLOOD", pkt.Header.RouteTypeName)
|
|
}
|
|
if pkt.Header.PayloadTypeName != "ADVERT" {
|
|
t.Errorf("payload=%s, want ADVERT", pkt.Header.PayloadTypeName)
|
|
}
|
|
if pkt.Path.HashSize != 2 {
|
|
t.Errorf("hashSize=%d, want 2", pkt.Path.HashSize)
|
|
}
|
|
if pkt.Path.HashCount != 5 {
|
|
t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount)
|
|
}
|
|
if pkt.Path.Hops[0] != "1000" {
|
|
t.Errorf("hop[0]=%s, want 1000", pkt.Path.Hops[0])
|
|
}
|
|
if pkt.Path.Hops[1] != "D818" {
|
|
t.Errorf("hop[1]=%s, want D818", pkt.Path.Hops[1])
|
|
}
|
|
if pkt.TransportCodes != nil {
|
|
t.Error("FLOOD should have no transport codes")
|
|
}
|
|
}
|
|
|
|
// --- Channel decryption tests ---
|
|
|
|
// buildTestCiphertext creates a valid AES-128-ECB encrypted GRP_TXT payload
|
|
// with a matching HMAC-SHA256 MAC for testing.
|
|
func buildTestCiphertext(channelKeyHex, senderMsg string, timestamp uint32) (ciphertextHex, macHex string) {
|
|
channelKey, _ := hex.DecodeString(channelKeyHex)
|
|
|
|
// Build plaintext: timestamp(4 LE) + flags(1) + message
|
|
plain := make([]byte, 4+1+len(senderMsg))
|
|
binary.LittleEndian.PutUint32(plain[0:4], timestamp)
|
|
plain[4] = 0x00 // flags
|
|
copy(plain[5:], senderMsg)
|
|
|
|
// Pad to AES block boundary
|
|
pad := aes.BlockSize - (len(plain) % aes.BlockSize)
|
|
if pad != aes.BlockSize {
|
|
plain = append(plain, make([]byte, pad)...)
|
|
}
|
|
|
|
// AES-128-ECB encrypt
|
|
block, _ := aes.NewCipher(channelKey)
|
|
ct := make([]byte, len(plain))
|
|
for i := 0; i < len(plain); i += aes.BlockSize {
|
|
block.Encrypt(ct[i:i+aes.BlockSize], plain[i:i+aes.BlockSize])
|
|
}
|
|
|
|
// HMAC-SHA256 MAC (first 2 bytes)
|
|
secret := make([]byte, 32)
|
|
copy(secret, channelKey)
|
|
h := hmac.New(sha256.New, secret)
|
|
h.Write(ct)
|
|
mac := h.Sum(nil)
|
|
|
|
return hex.EncodeToString(ct), hex.EncodeToString(mac[:2])
|
|
}
|
|
|
|
func TestDecryptChannelMessageValid(t *testing.T) {
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
ctHex, macHex := buildTestCiphertext(key, "Alice: Hello world", 1700000000)
|
|
|
|
result, err := decryptChannelMessage(ctHex, macHex, key)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Sender != "Alice" {
|
|
t.Errorf("sender=%q, want Alice", result.Sender)
|
|
}
|
|
if result.Message != "Hello world" {
|
|
t.Errorf("message=%q, want 'Hello world'", result.Message)
|
|
}
|
|
if result.Timestamp != 1700000000 {
|
|
t.Errorf("timestamp=%d, want 1700000000", result.Timestamp)
|
|
}
|
|
}
|
|
|
|
func TestDecryptChannelMessageMACFail(t *testing.T) {
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
ctHex, _ := buildTestCiphertext(key, "Alice: Hello", 100)
|
|
wrongMac := "ffff"
|
|
|
|
_, err := decryptChannelMessage(ctHex, wrongMac, key)
|
|
if err == nil {
|
|
t.Fatal("expected MAC verification failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "MAC") {
|
|
t.Errorf("error should mention MAC: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecryptChannelMessageWrongKey(t *testing.T) {
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
ctHex, macHex := buildTestCiphertext(key, "Alice: Hello", 100)
|
|
wrongKey := "deadbeefdeadbeefdeadbeefdeadbeef"
|
|
|
|
_, err := decryptChannelMessage(ctHex, macHex, wrongKey)
|
|
if err == nil {
|
|
t.Fatal("expected error with wrong key")
|
|
}
|
|
}
|
|
|
|
func TestDecryptChannelMessageNoSender(t *testing.T) {
|
|
key := "aaaabbbbccccddddaaaabbbbccccdddd"
|
|
ctHex, macHex := buildTestCiphertext(key, "Just a message", 500)
|
|
|
|
result, err := decryptChannelMessage(ctHex, macHex, key)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Sender != "" {
|
|
t.Errorf("sender=%q, want empty", result.Sender)
|
|
}
|
|
if result.Message != "Just a message" {
|
|
t.Errorf("message=%q, want 'Just a message'", result.Message)
|
|
}
|
|
}
|
|
|
|
func TestDecryptChannelMessageSenderWithBrackets(t *testing.T) {
|
|
key := "aaaabbbbccccddddaaaabbbbccccdddd"
|
|
ctHex, macHex := buildTestCiphertext(key, "[admin]: Not a sender", 500)
|
|
|
|
result, err := decryptChannelMessage(ctHex, macHex, key)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Sender != "" {
|
|
t.Errorf("sender=%q, want empty (brackets disqualify)", result.Sender)
|
|
}
|
|
if result.Message != "[admin]: Not a sender" {
|
|
t.Errorf("message=%q", result.Message)
|
|
}
|
|
}
|
|
|
|
func TestDecryptChannelMessageInvalidKey(t *testing.T) {
|
|
_, err := decryptChannelMessage("aabb", "cc", "ZZZZ")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid key hex")
|
|
}
|
|
}
|
|
|
|
func TestDecryptChannelMessageShortKey(t *testing.T) {
|
|
_, err := decryptChannelMessage("aabb", "cc", "aabb")
|
|
if err == nil {
|
|
t.Fatal("expected error for short key")
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtWithDecryption(t *testing.T) {
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
ctHex, macHex := buildTestCiphertext(key, "Bob: Testing 123", 1700000000)
|
|
macBytes, _ := hex.DecodeString(macHex)
|
|
ctBytes, _ := hex.DecodeString(ctHex)
|
|
|
|
// Build GRP_TXT payload: channelHash(1) + MAC(2) + encrypted
|
|
buf := []byte{0xAA}
|
|
buf = append(buf, macBytes...)
|
|
buf = append(buf, ctBytes...)
|
|
|
|
keys := map[string]string{"#test": key}
|
|
p := decodeGrpTxt(buf, keys)
|
|
|
|
if p.Type != "CHAN" {
|
|
t.Errorf("type=%s, want CHAN", p.Type)
|
|
}
|
|
if p.DecryptionStatus != "decrypted" {
|
|
t.Errorf("decryptionStatus=%s, want decrypted", p.DecryptionStatus)
|
|
}
|
|
if p.Channel != "#test" {
|
|
t.Errorf("channel=%s, want #test", p.Channel)
|
|
}
|
|
if p.Sender != "Bob" {
|
|
t.Errorf("sender=%q, want Bob", p.Sender)
|
|
}
|
|
if p.Text != "Bob: Testing 123" {
|
|
t.Errorf("text=%q, want 'Bob: Testing 123'", p.Text)
|
|
}
|
|
if p.ChannelHash != 0xAA {
|
|
t.Errorf("channelHash=%d, want 0xAA", p.ChannelHash)
|
|
}
|
|
if p.ChannelHashHex != "AA" {
|
|
t.Errorf("channelHashHex=%s, want AA", p.ChannelHashHex)
|
|
}
|
|
if p.SenderTimestamp != 1700000000 {
|
|
t.Errorf("senderTimestamp=%d, want 1700000000", p.SenderTimestamp)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtDecryptionFailed(t *testing.T) {
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
ctHex, macHex := buildTestCiphertext(key, "Hello", 100)
|
|
macBytes, _ := hex.DecodeString(macHex)
|
|
ctBytes, _ := hex.DecodeString(ctHex)
|
|
|
|
buf := []byte{0xFF}
|
|
buf = append(buf, macBytes...)
|
|
buf = append(buf, ctBytes...)
|
|
|
|
wrongKeys := map[string]string{"#wrong": "deadbeefdeadbeefdeadbeefdeadbeef"}
|
|
p := decodeGrpTxt(buf, wrongKeys)
|
|
|
|
if p.Type != "GRP_TXT" {
|
|
t.Errorf("type=%s, want GRP_TXT", p.Type)
|
|
}
|
|
if p.DecryptionStatus != "decryption_failed" {
|
|
t.Errorf("decryptionStatus=%s, want decryption_failed", p.DecryptionStatus)
|
|
}
|
|
if p.ChannelHashHex != "FF" {
|
|
t.Errorf("channelHashHex=%s, want FF", p.ChannelHashHex)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtNoKey(t *testing.T) {
|
|
buf := []byte{0x03, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22}
|
|
p := decodeGrpTxt(buf, nil)
|
|
|
|
if p.Type != "GRP_TXT" {
|
|
t.Errorf("type=%s, want GRP_TXT", p.Type)
|
|
}
|
|
if p.DecryptionStatus != "no_key" {
|
|
t.Errorf("decryptionStatus=%s, want no_key", p.DecryptionStatus)
|
|
}
|
|
if p.ChannelHashHex != "03" {
|
|
t.Errorf("channelHashHex=%s, want 03", p.ChannelHashHex)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtEmptyKeys(t *testing.T) {
|
|
buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22}
|
|
p := decodeGrpTxt(buf, map[string]string{})
|
|
|
|
if p.DecryptionStatus != "no_key" {
|
|
t.Errorf("decryptionStatus=%s, want no_key", p.DecryptionStatus)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtShortEncryptedNoDecryptAttempt(t *testing.T) {
|
|
// encryptedData < 5 bytes (10 hex chars) → should not attempt decryption
|
|
buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD}
|
|
keys := map[string]string{"#test": "2cc3d22840e086105ad73443da2cacb8"}
|
|
p := decodeGrpTxt(buf, keys)
|
|
|
|
if p.DecryptionStatus != "no_key" {
|
|
t.Errorf("decryptionStatus=%s, want no_key (too short for decryption)", p.DecryptionStatus)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtMultipleKeysTriesAll(t *testing.T) {
|
|
correctKey := "2cc3d22840e086105ad73443da2cacb8"
|
|
ctHex, macHex := buildTestCiphertext(correctKey, "Eve: Found it", 999)
|
|
macBytes, _ := hex.DecodeString(macHex)
|
|
ctBytes, _ := hex.DecodeString(ctHex)
|
|
|
|
buf := []byte{0x01}
|
|
buf = append(buf, macBytes...)
|
|
buf = append(buf, ctBytes...)
|
|
|
|
keys := map[string]string{
|
|
"#wrong1": "deadbeefdeadbeefdeadbeefdeadbeef",
|
|
"#correct": correctKey,
|
|
"#wrong2": "11111111111111111111111111111111",
|
|
}
|
|
p := decodeGrpTxt(buf, keys)
|
|
|
|
if p.Type != "CHAN" {
|
|
t.Errorf("type=%s, want CHAN", p.Type)
|
|
}
|
|
if p.Channel != "#correct" {
|
|
t.Errorf("channel=%s, want #correct", p.Channel)
|
|
}
|
|
if p.Sender != "Eve" {
|
|
t.Errorf("sender=%q, want Eve", p.Sender)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtChannelHashHexZeroPad(t *testing.T) {
|
|
buf := []byte{0x03, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}
|
|
p := decodeGrpTxt(buf, nil)
|
|
if p.ChannelHashHex != "03" {
|
|
t.Errorf("channelHashHex=%s, want 03 (zero-padded)", p.ChannelHashHex)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtChannelHashHexFF(t *testing.T) {
|
|
buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}
|
|
p := decodeGrpTxt(buf, nil)
|
|
if p.ChannelHashHex != "FF" {
|
|
t.Errorf("channelHashHex=%s, want FF", p.ChannelHashHex)
|
|
}
|
|
}
|
|
|
|
// --- Garbage text detection (fixes #197) ---
|
|
|
|
func TestDecryptChannelMessageGarbageText(t *testing.T) {
|
|
// Build ciphertext with binary garbage as the message
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
garbage := "\x01\x02\x03\x80\x81"
|
|
ctHex, macHex := buildTestCiphertext(key, garbage, 1700000000)
|
|
|
|
_, err := decryptChannelMessage(ctHex, macHex, key)
|
|
if err == nil {
|
|
t.Fatal("expected error for garbage text, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "non-printable") {
|
|
t.Errorf("error should mention non-printable: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecryptChannelMessageValidText(t *testing.T) {
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
ctHex, macHex := buildTestCiphertext(key, "Alice: Hello\nworld", 1700000000)
|
|
|
|
result, err := decryptChannelMessage(ctHex, macHex, key)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for valid text: %v", err)
|
|
}
|
|
if result.Sender != "Alice" {
|
|
t.Errorf("sender=%q, want Alice", result.Sender)
|
|
}
|
|
if result.Message != "Hello\nworld" {
|
|
t.Errorf("message=%q, want 'Hello\\nworld'", result.Message)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGrpTxtGarbageMarkedFailed(t *testing.T) {
|
|
key := "2cc3d22840e086105ad73443da2cacb8"
|
|
garbage := "\x01\x02\x03\x04\x05"
|
|
ctHex, macHex := buildTestCiphertext(key, garbage, 1700000000)
|
|
|
|
macBytes, _ := hex.DecodeString(macHex)
|
|
ctBytes, _ := hex.DecodeString(ctHex)
|
|
buf := make([]byte, 1+2+len(ctBytes))
|
|
buf[0] = 0xFF // channel hash
|
|
buf[1] = macBytes[0]
|
|
buf[2] = macBytes[1]
|
|
copy(buf[3:], ctBytes)
|
|
|
|
keys := map[string]string{"#general": key}
|
|
p := decodeGrpTxt(buf, keys)
|
|
|
|
if p.DecryptionStatus != "decryption_failed" {
|
|
t.Errorf("decryptionStatus=%s, want decryption_failed", p.DecryptionStatus)
|
|
}
|
|
if p.Type != "GRP_TXT" {
|
|
t.Errorf("type=%s, want GRP_TXT", p.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertWithTelemetry(t *testing.T) {
|
|
pubkey := strings.Repeat("AA", 32)
|
|
timestamp := "78563412"
|
|
signature := strings.Repeat("BB", 64)
|
|
flags := "94" // sensor(4) | hasLocation(0x10) | hasName(0x80)
|
|
lat := "40933402"
|
|
lon := "E0E6B8F8"
|
|
name := hex.EncodeToString([]byte("Sensor1"))
|
|
nullTerm := "00"
|
|
batteryLE := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(batteryLE, 3700)
|
|
tempLE := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(tempLE, uint16(int16(2850)))
|
|
|
|
hexStr := "1200" + pubkey + timestamp + signature + flags + lat + lon +
|
|
name + nullTerm +
|
|
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
|
|
|
|
pkt, err := DecodePacket(hexStr, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if pkt.Payload.Name != "Sensor1" {
|
|
t.Errorf("name=%s, want Sensor1", pkt.Payload.Name)
|
|
}
|
|
if pkt.Payload.BatteryMv == nil {
|
|
t.Fatal("battery_mv should not be nil")
|
|
}
|
|
if *pkt.Payload.BatteryMv != 3700 {
|
|
t.Errorf("battery_mv=%d, want 3700", *pkt.Payload.BatteryMv)
|
|
}
|
|
if pkt.Payload.TemperatureC == nil {
|
|
t.Fatal("temperature_c should not be nil")
|
|
}
|
|
if math.Abs(*pkt.Payload.TemperatureC-28.50) > 0.01 {
|
|
t.Errorf("temperature_c=%f, want 28.50", *pkt.Payload.TemperatureC)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertWithTelemetryNegativeTemp(t *testing.T) {
|
|
pubkey := strings.Repeat("CC", 32)
|
|
timestamp := "00000000"
|
|
signature := strings.Repeat("DD", 64)
|
|
flags := "84" // sensor(4) | hasName(0x80), no location
|
|
name := hex.EncodeToString([]byte("Cold"))
|
|
nullTerm := "00"
|
|
batteryLE := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(batteryLE, 4200)
|
|
tempLE := make([]byte, 2)
|
|
var negTemp int16 = -550
|
|
binary.LittleEndian.PutUint16(tempLE, uint16(negTemp))
|
|
|
|
hexStr := "1200" + pubkey + timestamp + signature + flags +
|
|
name + nullTerm +
|
|
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
|
|
|
|
pkt, err := DecodePacket(hexStr, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if pkt.Payload.Name != "Cold" {
|
|
t.Errorf("name=%s, want Cold", pkt.Payload.Name)
|
|
}
|
|
if pkt.Payload.BatteryMv == nil || *pkt.Payload.BatteryMv != 4200 {
|
|
t.Errorf("battery_mv=%v, want 4200", pkt.Payload.BatteryMv)
|
|
}
|
|
if pkt.Payload.TemperatureC == nil {
|
|
t.Fatal("temperature_c should not be nil")
|
|
}
|
|
if math.Abs(*pkt.Payload.TemperatureC-(-5.50)) > 0.01 {
|
|
t.Errorf("temperature_c=%f, want -5.50", *pkt.Payload.TemperatureC)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertWithoutTelemetry(t *testing.T) {
|
|
pubkey := strings.Repeat("EE", 32)
|
|
timestamp := "00000000"
|
|
signature := strings.Repeat("FF", 64)
|
|
flags := "82" // repeater(2) | hasName(0x80)
|
|
name := hex.EncodeToString([]byte("Node1"))
|
|
|
|
hexStr := "1200" + pubkey + timestamp + signature + flags + name
|
|
pkt, err := DecodePacket(hexStr, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if pkt.Payload.Name != "Node1" {
|
|
t.Errorf("name=%s, want Node1", pkt.Payload.Name)
|
|
}
|
|
if pkt.Payload.BatteryMv != nil {
|
|
t.Errorf("battery_mv should be nil for advert without telemetry, got %d", *pkt.Payload.BatteryMv)
|
|
}
|
|
if pkt.Payload.TemperatureC != nil {
|
|
t.Errorf("temperature_c should be nil for advert without telemetry, got %f", *pkt.Payload.TemperatureC)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertNonSensorIgnoresTelemetryBytes(t *testing.T) {
|
|
// A repeater node with 4 trailing bytes after the name should NOT decode telemetry.
|
|
pubkey := strings.Repeat("AB", 32)
|
|
timestamp := "00000000"
|
|
signature := strings.Repeat("CD", 64)
|
|
flags := "82" // repeater(2) | hasName(0x80)
|
|
name := hex.EncodeToString([]byte("Rptr"))
|
|
nullTerm := "00"
|
|
extraBytes := "B40ED403" // battery-like and temp-like bytes
|
|
|
|
hexStr := "1200" + pubkey + timestamp + signature + flags + name + nullTerm + extraBytes
|
|
pkt, err := DecodePacket(hexStr, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Payload.BatteryMv != nil {
|
|
t.Errorf("battery_mv should be nil for non-sensor node, got %d", *pkt.Payload.BatteryMv)
|
|
}
|
|
if pkt.Payload.TemperatureC != nil {
|
|
t.Errorf("temperature_c should be nil for non-sensor node, got %f", *pkt.Payload.TemperatureC)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) {
|
|
// 0°C is a valid temperature and must be emitted.
|
|
pubkey := strings.Repeat("12", 32)
|
|
timestamp := "00000000"
|
|
signature := strings.Repeat("34", 64)
|
|
flags := "84" // sensor(4) | hasName(0x80)
|
|
name := hex.EncodeToString([]byte("FreezeSensor"))
|
|
nullTerm := "00"
|
|
batteryLE := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(batteryLE, 3600)
|
|
tempLE := make([]byte, 2) // tempRaw=0 → 0°C
|
|
|
|
hexStr := "1200" + pubkey + timestamp + signature + flags +
|
|
name + nullTerm +
|
|
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
|
|
|
|
pkt, err := DecodePacket(hexStr, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pkt.Payload.TemperatureC == nil {
|
|
t.Fatal("temperature_c should not be nil for 0°C")
|
|
}
|
|
if *pkt.Payload.TemperatureC != 0.0 {
|
|
t.Errorf("temperature_c=%f, want 0.0", *pkt.Payload.TemperatureC)
|
|
}
|
|
}
|
|
|
|
func repeatHex(byteHex string, n int) string {
|
|
s := ""
|
|
for i := 0; i < n; i++ {
|
|
s += byteHex
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestZeroHopDirectHashSize(t *testing.T) {
|
|
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
|
// pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0
|
|
hex := "02" + "00" + repeatHex("AA", 20)
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 0 {
|
|
t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
|
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
|
// pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0
|
|
hex := "02" + "40" + repeatHex("AA", 20)
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 0 {
|
|
t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) {
|
|
// FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01
|
|
// pathByte=0x00 → non-DIRECT should keep HashSize=1
|
|
hex := "01" + "00" + repeatHex("AA", 20)
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 1 {
|
|
t.Errorf("FLOOD zero pathByte: want HashSize=1, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func TestDirectNonZeroHopKeepsHashSize(t *testing.T) {
|
|
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
|
|
// pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1
|
|
hex := "02" + "01" + repeatHex("BB", 21)
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 1 {
|
|
t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func TestZeroHopTransportDirectHashSize(t *testing.T) {
|
|
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
|
// 4 bytes transport codes + pathByte=0x00 → hash_count=0 → should get HashSize=0
|
|
hex := "03" + "11223344" + "00" + repeatHex("AA", 20)
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 0 {
|
|
t.Errorf("TRANSPORT_DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
|
// pathByte=0xC0 → hash_size bits=11 (4, reserved per firmware Packet.cpp:13-18).
|
|
// Firmware Packet::isValidPathLen rejects this regardless of hash_count,
|
|
// because hash_size==4 is reserved. Go decoder must mirror that — even
|
|
// when hash_count==0, an attacker-emitted 0xC0 byte should not be
|
|
// silently accepted; firmware never emits hash_size==4.
|
|
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
|
|
_, err := DecodePacket(hex, nil, false)
|
|
if err == nil {
|
|
t.Fatalf("DecodePacket(pathByte=0xC0) succeeded; want error mirroring firmware Packet.cpp:13-18 (hash_size==4 reserved)")
|
|
}
|
|
}
|
|
|
|
func TestValidateAdvertSignature(t *testing.T) {
|
|
// Generate a real ed25519 key pair
|
|
pub, priv, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var timestamp uint32 = 1234567890
|
|
appdata := []byte{0x02, 0x11, 0x22} // flags + some data
|
|
|
|
// Build the signed message: pubKey + timestamp(LE) + appdata
|
|
message := make([]byte, 32+4+len(appdata))
|
|
copy(message[0:32], pub)
|
|
binary.LittleEndian.PutUint32(message[32:36], timestamp)
|
|
copy(message[36:], appdata)
|
|
|
|
sig := ed25519.Sign(priv, message)
|
|
|
|
// Valid signature
|
|
valid, err := sigvalidate.ValidateAdvert([]byte(pub), sig, timestamp, appdata)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !valid {
|
|
t.Error("expected valid signature")
|
|
}
|
|
|
|
// Tampered appdata → invalid
|
|
badAppdata := []byte{0x03, 0x11, 0x22}
|
|
valid, err = sigvalidate.ValidateAdvert([]byte(pub), sig, timestamp, badAppdata)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if valid {
|
|
t.Error("expected invalid signature with tampered appdata")
|
|
}
|
|
|
|
// Wrong timestamp → invalid
|
|
valid, err = sigvalidate.ValidateAdvert([]byte(pub), sig, timestamp+1, appdata)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if valid {
|
|
t.Error("expected invalid signature with wrong timestamp")
|
|
}
|
|
|
|
// Wrong length pubkey
|
|
_, err = sigvalidate.ValidateAdvert([]byte{0xAA, 0xBB}, sig, timestamp, appdata)
|
|
if err == nil {
|
|
t.Error("expected error for short pubkey")
|
|
}
|
|
|
|
// Wrong length signature
|
|
_, err = sigvalidate.ValidateAdvert([]byte(pub), []byte{0xAA, 0xBB}, timestamp, appdata)
|
|
if err == nil {
|
|
t.Error("expected error for short signature")
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertWithSignatureValidation(t *testing.T) {
|
|
// Generate key pair
|
|
pub, priv, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var timestamp uint32 = 1000000
|
|
appdata := []byte{0x02} // repeater type, no location
|
|
|
|
// Build signed message
|
|
message := make([]byte, 32+4+len(appdata))
|
|
copy(message[0:32], pub)
|
|
binary.LittleEndian.PutUint32(message[32:36], timestamp)
|
|
copy(message[36:], appdata)
|
|
sig := ed25519.Sign(priv, message)
|
|
|
|
// Build advert buffer: pubkey(32) + timestamp(4) + signature(64) + appdata
|
|
buf := make([]byte, 0, 101)
|
|
buf = append(buf, pub...)
|
|
ts := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(ts, timestamp)
|
|
buf = append(buf, ts...)
|
|
buf = append(buf, sig...)
|
|
buf = append(buf, appdata...)
|
|
|
|
// With validation enabled
|
|
p := decodeAdvert(buf, true)
|
|
if p.Error != "" {
|
|
t.Fatalf("decode error: %s", p.Error)
|
|
}
|
|
if p.SignatureValid == nil {
|
|
t.Fatal("SignatureValid should be set when validation enabled")
|
|
}
|
|
if !*p.SignatureValid {
|
|
t.Error("expected valid signature")
|
|
}
|
|
|
|
// Without validation
|
|
p2 := decodeAdvert(buf, false)
|
|
if p2.SignatureValid != nil {
|
|
t.Error("SignatureValid should be nil when validation disabled")
|
|
}
|
|
}
|
|
|
|
// === Tests for DecodePathFromRawHex (issue #886) ===
|
|
|
|
func TestDecodePathFromRawHex_HashSize1(t *testing.T) {
|
|
// Header byte 0x26 = route_type DIRECT, payload TRACE
|
|
// Path byte 0x04 = hash_size 1 (bits 7-6 = 00 → 0+1=1), hash_count 4
|
|
// Path bytes: 30 2D 0D 23
|
|
raw := "2604302D0D2359FEE7B100000000006733D63367"
|
|
hops, err := packetpath.DecodePathFromRawHex(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := []string{"30", "2D", "0D", "23"}
|
|
if len(hops) != len(expected) {
|
|
t.Fatalf("got %d hops, want %d", len(hops), len(expected))
|
|
}
|
|
for i, h := range hops {
|
|
if h != expected[i] {
|
|
t.Errorf("hop[%d] = %s, want %s", i, h, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodePathFromRawHex_HashSize2(t *testing.T) {
|
|
// Path byte 0x42 = hash_size 2 (bits 7-6 = 01 → 1+1=2), hash_count 2
|
|
// Header 0x09 = FLOOD route (rt=1), payload ADVERT (pt=2)
|
|
// Path bytes: AABB CCDD (4 bytes = 2 hops * 2 bytes)
|
|
raw := "0942AABBCCDD" + "00000000000000"
|
|
hops, err := packetpath.DecodePathFromRawHex(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := []string{"AABB", "CCDD"}
|
|
if len(hops) != len(expected) {
|
|
t.Fatalf("got %d hops, want %d", len(hops), len(expected))
|
|
}
|
|
for i, h := range hops {
|
|
if h != expected[i] {
|
|
t.Errorf("hop[%d] = %s, want %s", i, h, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodePathFromRawHex_HashSize3(t *testing.T) {
|
|
// Path byte 0x81 = hash_size 3 (bits 7-6 = 10 → 2+1=3), hash_count 1
|
|
// Header 0x09 = FLOOD route (rt=1), payload ADVERT
|
|
raw := "0981AABBCC" + "0000000000"
|
|
hops, err := packetpath.DecodePathFromRawHex(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(hops) != 1 || hops[0] != "AABBCC" {
|
|
t.Fatalf("got %v, want [AABBCC]", hops)
|
|
}
|
|
}
|
|
|
|
func TestDecodePathFromRawHex_HashSize4(t *testing.T) {
|
|
// Path byte 0xC1 = hash_size 4 (bits 7-6 = 11 → 3+1=4), hash_count 1
|
|
// Header 0x09 = FLOOD route (rt=1)
|
|
raw := "09C1AABBCCDD" + "0000000000"
|
|
hops, err := packetpath.DecodePathFromRawHex(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(hops) != 1 || hops[0] != "AABBCCDD" {
|
|
t.Fatalf("got %v, want [AABBCCDD]", hops)
|
|
}
|
|
}
|
|
|
|
func TestDecodePathFromRawHex_DirectZeroHops(t *testing.T) {
|
|
// Path byte 0x00 = hash_size 1, hash_count 0
|
|
// Header 0x0A = DIRECT route (rt=2), payload ADVERT
|
|
raw := "0A00" + "0000000000"
|
|
hops, err := packetpath.DecodePathFromRawHex(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(hops) != 0 {
|
|
t.Fatalf("got %d hops, want 0", len(hops))
|
|
}
|
|
}
|
|
|
|
func TestDecodePathFromRawHex_Transport(t *testing.T) {
|
|
// Route type 3 = TRANSPORT_DIRECT → 4 transport code bytes before path byte
|
|
// Header 0x27 = route_type 3, payload TRACE
|
|
// Transport codes: 1122 3344
|
|
// Path byte 0x02 = hash_size 1, hash_count 2
|
|
// Path bytes: AA BB
|
|
raw := "2711223344" + "02AABB" + "0000000000"
|
|
hops, err := packetpath.DecodePathFromRawHex(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := []string{"AA", "BB"}
|
|
if len(hops) != len(expected) {
|
|
t.Fatalf("got %d hops, want %d", len(hops), len(expected))
|
|
}
|
|
for i, h := range hops {
|
|
if h != expected[i] {
|
|
t.Errorf("hop[%d] = %s, want %s", i, h, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodeTracePayloadFailSetsAnomaly(t *testing.T) {
|
|
// Issue #889: TRACE packet with payload too short to decode (< 9 bytes)
|
|
// should still return a DecodedPacket (observation stored) but with Anomaly
|
|
// set to warn operators that the decode was degraded.
|
|
// Packet: header 0x26 (TRACE+DIRECT), pathByte 0x00, payload 4 bytes (too short).
|
|
pkt, err := DecodePacket("2600aabbccdd", nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.Payload.Type != "TRACE" {
|
|
t.Fatalf("payload type=%s, want TRACE", pkt.Payload.Type)
|
|
}
|
|
if pkt.Payload.Error == "" {
|
|
t.Fatal("expected payload.Error to indicate decode failure")
|
|
}
|
|
// The key assertion: Anomaly must be set when TRACE decode fails
|
|
if pkt.Anomaly == "" {
|
|
t.Error("expected Anomaly to be set when TRACE payload decode fails but observation is stored")
|
|
}
|
|
}
|
|
|
|
// TestDecodeTraceExtractsSNRValues verifies that for TRACE packets, the header
|
|
// path bytes are interpreted as int8 SNR values (quarter-dB) and exposed via
|
|
// payload.SNRValues. Mirrors logic in cmd/server/decoder.go (issue: SNR values
|
|
// extracted by server but never written into decoded_json by ingestor).
|
|
//
|
|
// Packet 26022FF8116A23A80000000001C0DE1000DEDE:
|
|
//
|
|
// header 0x26 → TRACE (pt=9), DIRECT (rt=2)
|
|
// pathByte 0x02 → hash_size=1, hash_count=2
|
|
// header path: 2F F8 → SNR = [int8(0x2F)/4, int8(0xF8)/4] = [11.75, -2.0]
|
|
// payload (15B): tag=116A23A8 auth=00000000 flags=0x01 pathData=C0DE1000DEDE
|
|
func TestDecodeTraceExtractsSNRValues(t *testing.T) {
|
|
pkt, err := DecodePacket("26022FF8116A23A80000000001C0DE1000DEDE", nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.Payload.Type != "TRACE" {
|
|
t.Fatalf("payload type=%s, want TRACE", pkt.Payload.Type)
|
|
}
|
|
if len(pkt.Payload.SNRValues) != 2 {
|
|
t.Fatalf("len(SNRValues)=%d, want 2 (got %v)", len(pkt.Payload.SNRValues), pkt.Payload.SNRValues)
|
|
}
|
|
if pkt.Payload.SNRValues[0] != 11.75 {
|
|
t.Errorf("SNRValues[0]=%v, want 11.75", pkt.Payload.SNRValues[0])
|
|
}
|
|
if pkt.Payload.SNRValues[1] != -2.0 {
|
|
t.Errorf("SNRValues[1]=%v, want -2.0", pkt.Payload.SNRValues[1])
|
|
}
|
|
}
|
|
|
|
// TestDecodePacketBoundsFromWire — regression for issue #1211.
|
|
//
|
|
// A malformed packet on the wire claimed pathByte=0xF6 (hash_size=4, hash_count=54
|
|
// → 216 path bytes) inside a 15-byte buffer. decodePath() returned bytesConsumed=216
|
|
// without bounds-check, causing the outer slice `payloadBuf := buf[offset:]` to
|
|
// blow up with `slice bounds out of range [218:15]`.
|
|
//
|
|
// Expected behaviour: DecodePacket MUST NOT panic on any input. If the path
|
|
// length claimed by the wire byte exceeds the buffer, it should return a
|
|
// clean error.
|
|
func TestDecodePacketBoundsFromWire_Issue1211(t *testing.T) {
|
|
// 15-byte buffer: header=0x12 (rt=DIRECT, pt=ADVERT), pathByte=0xF6
|
|
// (hash_size=4, hash_count=54 → claims 216 path bytes), + 13 garbage bytes.
|
|
raw := "12F6" + strings.Repeat("AA", 13)
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("DecodePacket panicked on malformed input: %v", r)
|
|
}
|
|
}()
|
|
pkt, err := DecodePacket(raw, nil, false)
|
|
if err == nil {
|
|
t.Fatalf("expected error for malformed packet (path claims 216 bytes in 15-byte buf), got nil; pkt=%+v", pkt)
|
|
}
|
|
}
|
|
|
|
// TestDecodePacketFuzzTruncated — sweep the decoder with truncated payloads.
|
|
// Zero panics is the acceptance bar.
|
|
//
|
|
// Adv M2: the original loop ran 256*256*20 = 1.3M iterations on every
|
|
// `go test` (in both packages, so 2.6M total). That is not "fuzzing" — it
|
|
// is an expensive deterministic sweep that runs in the default unit-test
|
|
// path with no opt-in. We now:
|
|
//
|
|
// - gate the exhaustive sweep on !testing.Short() so `go test -short`
|
|
// skips it (CI's unit gate runs short)
|
|
// - keep the full sweep under `go test ./...` to preserve coverage
|
|
// - prefer `go test -fuzz=FuzzDecodePacketTruncated` for actual
|
|
// randomized fuzzing (see FuzzDecodePacketTruncated below)
|
|
func TestDecodePacketFuzzTruncated_Issue1211(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("DecodePacket panicked during fuzz: %v", r)
|
|
}
|
|
}()
|
|
if testing.Short() {
|
|
t.Skip("skipping exhaustive sweep in -short mode; use FuzzDecodePacketTruncated")
|
|
}
|
|
// Sweep every pathByte value with a short tail.
|
|
for hdr := 0; hdr < 256; hdr++ {
|
|
for pb := 0; pb < 256; pb++ {
|
|
for tail := 0; tail < 20; tail++ {
|
|
raw := hex.EncodeToString([]byte{byte(hdr), byte(pb)}) + strings.Repeat("00", tail)
|
|
_, _ = DecodePacket(raw, nil, false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FuzzDecodePacketTruncated — native go fuzz target. Run with:
|
|
//
|
|
// go test -fuzz=FuzzDecodePacketTruncated -fuzztime=30s ./cmd/ingestor
|
|
//
|
|
// Zero panics regardless of input is the acceptance bar.
|
|
func FuzzDecodePacketTruncated(f *testing.F) {
|
|
seeds := [][]byte{
|
|
{0x12, 0xF6, 0xAA, 0xAA, 0xAA},
|
|
{0x12, 0x00},
|
|
{0x03, 0x11, 0x22, 0x33, 0x44, 0xC0, 0xAA, 0xAA, 0xAA},
|
|
}
|
|
for _, s := range seeds {
|
|
f.Add(s)
|
|
}
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("DecodePacket panicked on input %x: %v", data, r)
|
|
}
|
|
}()
|
|
_, _ = DecodePacket(hex.EncodeToString(data), nil, false)
|
|
})
|
|
}
|
|
|
|
// TestDecodeAdvertOversizedNameTruncated asserts decodeAdvert truncates the
|
|
// advert name to firmware's MAX_ADVERT_DATA_SIZE=32 (firmware/src/MeshCore.h:11).
|
|
// Firmware writes the node name into a 32-byte buffer, so any on-wire advert
|
|
// carrying >32 bytes of name data is adversarial — the Go decoder must not
|
|
// surface attacker-controlled bytes beyond what firmware would ever emit.
|
|
func TestDecodeAdvertOversizedNameTruncated(t *testing.T) {
|
|
pubkey := repeatHex("AA", 32)
|
|
timestamp := "78563412"
|
|
signature := repeatHex("BB", 64)
|
|
flags := "81" // chat(1) | hasName(0x80), no location, no feat1/2
|
|
// 64-byte ASCII 'X' name with no null terminator (firmware buffer is 32 bytes).
|
|
name := repeatHex("58", 64)
|
|
hex := "1200" + pubkey + timestamp + signature + flags + name
|
|
pkt, err := DecodePacket(hex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket: %v", err)
|
|
}
|
|
if got := len(pkt.Payload.Name); got > 32 {
|
|
t.Errorf("name length=%d, want <=32 (MAX_ADVERT_DATA_SIZE firmware/src/MeshCore.h:11)", got)
|
|
}
|
|
}
|