Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@
"pad3": "readonly",
"pages": "readonly",
"parseViewportHash": "readonly",
"pathHashSize": "readonly",
"payloadTypeColor": "readonly",
"payloadTypeName": "readonly",
"process": "readonly",
Expand Down
131 changes: 131 additions & 0 deletions cmd/server/channel_message_hash_size_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package main

import (
"encoding/json"
"testing"
"time"
)

// The Channels view shows the path hash size (1-3 bytes) each message was
// sent with, as path_hash_size (not hash_size: nodes already use that key for
// the size a node is observed to use). /api/channels/{hash}/messages is served
// by the DB query when a DB is attached and by the in-memory store otherwise,
// and the DB query has a v3 and a pre-v3 shape, so all three must carry it.
// 0 = the packet does not encode one (see packetpath.HashSize).

var chHashSizeWant = map[string]float64{
"dddddddddddddd01": 1, // flood, 1-byte, 2 hops
"dddddddddddddd02": 2, // flood, 2-byte, heard direct (0 hops)
"dddddddddddddd03": 3, // transport flood, 3-byte, 1 hop
"dddddddddddddd04": 0, // direct zero-hop: no size encoded
}

func setupChannelHashSizeDB(t *testing.T) *DB {
t.Helper()
return seedChannelHashSize(t, setupTestDB(t))
}

// seedChannelHashSize writes one channel message per chHashSizeWant entry, each
// heard by one observation, in the observation shape of db's schema.
func seedChannelHashSize(t *testing.T, db *DB) *DB {
t.Helper()
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'BRU')`); err != nil {
t.Fatalf("insert observer: %v", err)
}
now := time.Now().UTC()
rows := []struct {
hash, rawHex string
routeType int
}{
{"dddddddddddddd01", "1502AABBDEADBEEF", 1},
{"dddddddddddddd02", "1540DEADBEEF", 1},
{"dddddddddddddd03", "141122334481AABBCCDEADBEEF", 0},
{"dddddddddddddd04", "1600DEADBEEF", 2},
}
for i, r := range rows {
ts := now.Add(time.Duration(i-len(rows)) * time.Minute)
res, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES (?, ?, ?, ?, 5, '{"type":"CHAN","channel":"#hashsize","text":"Alice: msg"}', '#hashsize')`,
r.rawHex, r.hash, ts.Format(time.RFC3339), r.routeType)
if err != nil {
t.Fatalf("insert tx %s: %v", r.hash, err)
}
txID, _ := res.LastInsertId()
obsSQL := `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (?, 1, 9.5, -90, '[]', ?)`
if !db.isV3 {
obsSQL = `INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp)
VALUES (?, 'obs1', 'Observer One', 9.5, -90, '[]', ?)`
}
if _, err := db.conn.Exec(obsSQL, txID, ts.Unix()); err != nil {
t.Fatalf("insert obs %s: %v", r.hash, err)
}
}
return db
}

func assertChannelMessageHashSizes(t *testing.T, messages []map[string]interface{}) {
t.Helper()
if len(messages) != len(chHashSizeWant) {
t.Fatalf("expected %d messages, got %d", len(chHashSizeWant), len(messages))
}
for _, m := range messages {
// Compare on what the browser receives, not on the Go value type.
b, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
h, _ := decoded["packetHash"].(string)
want, known := chHashSizeWant[h]
if !known {
t.Errorf("unexpected packetHash %q", h)
continue
}
got, present := decoded["path_hash_size"]
if !present {
t.Errorf("%s: path_hash_size key missing", h)
continue
}
if got != want {
t.Errorf("%s: path_hash_size = %#v, want %v", h, got, want)
}
}
}

func TestDBGetChannelMessagesCarriesHashSize(t *testing.T) {
db := setupChannelHashSizeDB(t)
defer db.Close()
messages, _, err := db.GetChannelMessages("#hashsize", 100, 0)
if err != nil {
t.Fatal(err)
}
assertChannelMessageHashSizes(t, messages)
}

func TestDBGetChannelMessagesCarriesHashSizeV2Schema(t *testing.T) {
db := seedChannelHashSize(t, setupTestDBv2(t))
defer db.Close()
if db.isV3 {
t.Fatal("precondition: setupTestDBv2 must give the pre-v3 schema")
}
messages, _, err := db.GetChannelMessages("#hashsize", 100, 0)
if err != nil {
t.Fatal(err)
}
assertChannelMessageHashSizes(t, messages)
}

func TestStoreGetChannelMessagesCarriesHashSize(t *testing.T) {
db := setupChannelHashSizeDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load: %v", err)
}
messages, _ := store.GetChannelMessages("#hashsize", 100, 0)
assertChannelMessageHashSizes(t, messages)
}
12 changes: 8 additions & 4 deletions cmd/server/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/meshcore-analyzer/dbschema"
"github.com/meshcore-analyzer/geofilter"
"github.com/meshcore-analyzer/packetpath"
"golang.org/x/sync/singleflight"
)

Expand Down Expand Up @@ -2219,17 +2220,19 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
if db.hasScopeName {
scopeNameCol = ", t.scope_name"
}
// substr(t.raw_hex, 1, 12): packetpath.HashSize only reads the header,
// transport codes and path byte (6 bytes), not the whole packet.
var obsSQL string
if db.isV3 {
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, substr(t.raw_hex, 1, 12),
obs.id, obs.name, o.snr, o.path_json, o.timestamp` + scopeNameCol + `
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `)
ORDER BY o.id ASC`
} else {
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, substr(t.raw_hex, 1, 12),
o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp` + scopeNameCol + `
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
Expand All @@ -2252,11 +2255,11 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .

for rows.Next() {
var pktID, txID int
var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString
var pktHash, dj, fs, rawHexHead, obsID, obsName, pathJSON sql.NullString
var snr sql.NullFloat64
var obsTs sql.NullInt64
var scopeName sql.NullString
scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs}
scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &rawHexHead, &obsID, &obsName, &snr, &pathJSON, &obsTs}
if db.hasScopeName {
scanArgs = append(scanArgs, &scopeName)
}
Expand Down Expand Up @@ -2312,6 +2315,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
"hops": hops,
"snr": nullFloat(snr),
"scope_name": nullStr(scopeName),
"path_hash_size": packetpath.HashSize(rawHexHead.String),
},
Repeats: 1,
}
Expand Down
62 changes: 62 additions & 0 deletions cmd/server/path_hash_size_agreement_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"encoding/json"
"os"
"testing"

"github.com/meshcore-analyzer/packetpath"
)

// The server reads a packet's path hash size two ways: packetpath.HashSize
// (channel messages' path_hash_size) and the decoder's path.hashSize (shipped
// in every decoded_json). They must agree, or the Channels view and the
// packet detail can show different sizes for the same packet. The cases are
// the ones shared with public/app.js pathHashSize, so this test also runs that
// table in CI (internal/packetpath has no CI job of its own).
func TestPathHashSizeAgreesWithDecoder(t *testing.T) {
data, err := os.ReadFile("../../test-fixtures/path-hash-size-cases.json")
if err != nil {
t.Fatalf("read shared cases: %v", err)
}
var fixture struct {
Cases []struct {
Name string `json:"name"`
Raw string `json:"raw"`
Want int `json:"want"`
} `json:"cases"`
}
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("parse shared cases: %v", err)
}
if len(fixture.Cases) == 0 {
t.Fatal("shared cases file has no cases")
}

compared := 0
for _, c := range fixture.Cases {
if got := packetpath.HashSize(c.Raw); got != c.Want {
t.Errorf("%s: packetpath.HashSize(%q) = %d, want %d", c.Name, c.Raw, got, c.Want)
}
// The decoder needs a whole payload; the shared cases only carry the
// header and path, so pad them. Inputs it rejects (reserved size bits,
// truncated or invalid hex) have no decoder value to compare.
raw := c.Raw + repeatHex("AA", 40)
pkt, err := DecodePacket(raw, false)
if err != nil {
continue
}
// TRACE path bytes are SNR readings; the decoder's hashSize for TRACE
// comes from the trace flags and is not a path hash size.
if pkt.Header.PayloadType == PayloadTRACE {
continue
}
compared++
if helper := packetpath.HashSize(raw); pkt.Path.HashSize != helper {
t.Errorf("%s: decoder path.hashSize = %d, packetpath.HashSize = %d", c.Name, pkt.Path.HashSize, helper)
}
}
if compared == 0 {
t.Fatal("no shared case was decodable; the agreement check compared nothing")
}
}
2 changes: 2 additions & 0 deletions cmd/server/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"unicode/utf8"

"github.com/meshcore-analyzer/mbcapqueue"
"github.com/meshcore-analyzer/packetpath"
"golang.org/x/sync/singleflight"
)

Expand Down Expand Up @@ -5759,6 +5760,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
"hops": hops,
"snr": snrVal,
"scope_name": strPtrOrNil(tx.ScopeName),
"path_hash_size": packetpath.HashSize(tx.RawHex),
},
Repeats: 1,
Observers: observers,
Expand Down
46 changes: 46 additions & 0 deletions internal/packetpath/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,52 @@ func DecodePathFromRawHex(rawHex string) ([]string, error) {
return hops, nil
}

// HashSize returns the path hash size (1-3 bytes) the originator chose, read
// from the path byte of raw_hex, or 0 when the packet does not carry one.
//
// A flood packet always carries it: Mesh::sendFlood sets the path byte to
// (size-1)<<6 before the first hop, so it holds even at 0 hops. A direct
// packet with no hops carries none: Mesh::sendZeroHop writes 0x00, and a
// direct path that has run out of hops has no hash left to size. That is the
// same rule cmd/server/decoder.go applies to path.hashSize. TRACE path bytes
// are SNR readings, not hashes. Only the first few bytes are parsed, so this
// is cheap on hot paths.
func HashSize(rawHex string) int {
header, ok := hexByteAt(rawHex, 0)
if !ok || !PathBytesAreHops(header>>2&0x0F) {
return 0
}
routeType := int(header & 0x03)
offset := 1
if IsTransportRoute(routeType) {
offset += 4
}
pathByte, ok := hexByteAt(rawHex, offset)
if !ok {
return 0
}
if pathByte&0x3F == 0 && (routeType == RouteDirect || routeType == RouteTransportDirect) {
return 0
}
size := int(pathByte>>6) + 1
if size > 3 {
return 0 // 0b11 is reserved; firmware rejects sizes above 3
}
return size
}

// hexByteAt decodes the byte at byte index i of a hex string.
func hexByteAt(s string, i int) (byte, bool) {
if len(s) < 2*i+2 {
return 0, false
}
b, err := hex.DecodeString(s[2*i : 2*i+2])
if err != nil {
return 0, false
}
return b[0], true
}

// DecodeHopsForPayload returns the header path hops only when the payload type's
// header bytes are actually route hops (i.e. PathBytesAreHops(payloadType) is true).
// For TRACE packets it returns (nil, ErrPayloadHasNoHeaderHops) so the caller is
Expand Down
29 changes: 29 additions & 0 deletions internal/packetpath/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package packetpath
import (
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
)
Expand Down Expand Up @@ -148,3 +149,31 @@ func TestDecodeHopsForPayload_TraceReturnsError(t *testing.T) {
t.Errorf("expected nil hops for TRACE, got %v", hops)
}
}

// TestHashSize runs the cases shared with pathHashSize in public/app.js
// (tests/unit/test-frontend-helpers.js reads the same file), so the Go and JS
// implementations cannot drift apart without one of the two suites failing.
func TestHashSize(t *testing.T) {
data, err := os.ReadFile("../../test-fixtures/path-hash-size-cases.json")
if err != nil {
t.Fatalf("read shared cases: %v", err)
}
var fixture struct {
Cases []struct {
Name string `json:"name"`
Raw string `json:"raw"`
Want int `json:"want"`
} `json:"cases"`
}
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("parse shared cases: %v", err)
}
if len(fixture.Cases) == 0 {
t.Fatal("shared cases file has no cases")
}
for _, c := range fixture.Cases {
if got := HashSize(c.Raw); got != c.Want {
t.Errorf("%s: HashSize(%q) = %d, want %d", c.Name, c.Raw, got, c.Want)
}
}
}
23 changes: 23 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
function isTransportRoute(rt) { return rt === 0 || rt === 3; }
/** Byte offset of path_len in raw_hex: 5 for transport routes (4 bytes of next/last hop codes precede it), 1 otherwise. */
function getPathLenOffset(routeType) { return isTransportRoute(routeType) ? 5 : 1; }
/**
* Path hash size (1-3 bytes) the originator chose, from raw_hex's path byte, or
* 0 when the packet carries none. Same rule as packetpath.HashSize on the
* server; both run test-fixtures/path-hash-size-cases.json. A flood packet
* always encodes it (firmware sendFlood sets it before the first hop), a direct
* packet with no hops does not (sendZeroHop writes 0x00, and an exhausted
* direct path has no hash left to size), and TRACE path bytes are SNR readings.
*/
function pathHashSize(rawHex) {
// Names as in internal/packetpath/route.go.
const PAYLOAD_TRACE = 9, ROUTE_DIRECT = 2, ROUTE_TRANSPORT_DIRECT = 3;
if (typeof rawHex !== 'string' || !/^[0-9a-f]{2}/i.test(rawHex)) return 0;
const header = parseInt(rawHex.slice(0, 2), 16);
if (((header >> 2) & 0x0F) === PAYLOAD_TRACE) return 0;
const routeType = header & 0x03;
const off = getPathLenOffset(routeType) * 2;
const pathHex = rawHex.slice(off, off + 2);
if (!/^[0-9a-f]{2}$/i.test(pathHex)) return 0;
const pathByte = parseInt(pathHex, 16);
if ((pathByte & 0x3F) === 0 && (routeType === ROUTE_DIRECT || routeType === ROUTE_TRANSPORT_DIRECT)) return 0;
const size = (pathByte >> 6) + 1;
return size > 3 ? 0 : size;
}
function transportBadge(rt) { return isTransportRoute(rt) ? ' <span class="badge badge-transport" title="' + routeTypeName(rt) + '">T</span>' : ''; }

/**
Expand Down
Loading
Loading