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 @@ -270,6 +270,7 @@
"routeLayer": "readonly",
"routeTypeName": "readonly",
"scopeCellHtml": "readonly",
"senderPathHashSize": "readonly",
"setupPullToReconnect": "readonly",
"syncBadgeColors": "readonly",
"timeAgo": "readonly",
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1692-packets-init-parallel-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-147-packets-url-obs-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-packet-detail-sender-hash-size-obs-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-96-hide-control-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-180-packets-url-modal-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt
Expand Down
80 changes: 80 additions & 0 deletions cmd/server/channel_sender_hash_size_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package main

import (
"fmt"
"testing"
"time"
)

// Both channel-message backends must describe the stored frame, including a
// flood heard before any relay has appended a hop.
func TestChannelMessagesSenderPathHashSize(t *testing.T) {
for _, makeDB := range []struct {
name string
open func(*testing.T) *DB
}{
{"v3", func(t *testing.T) *DB { return setupTestDB(t) }},
{"v2", setupTestDBV2},
} {
t.Run(makeDB.name, func(t *testing.T) {
db := makeDB.open(t)
defer db.Close()
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs-hash', 'Observer', 'AAR')`); err != nil {
t.Fatal(err)
}
for i, tc := range []struct {
raw string
want int
}{
{"1540DEADBEEF", 2}, // zero-hop flood
{"1580DEADBEEF", 3}, // zero-hop flood
{"1600DEADBEEF", 0}, // direct marker, no encoded width
} {
ts := time.Now().UTC().Add(time.Duration(i-5) * 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":"#sender-size","text":"Alice: message"}', '#sender-size')`,
tc.raw, fmt.Sprintf("sender-size-%d", i), ts.Format(time.RFC3339), 1)
if err != nil {
t.Fatal(err)
}
id, _ := res.LastInsertId()
if makeDB.name == "v3" {
_, err = db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (?, 1, '[]', ?)`, id, ts.Unix())
} else {
_, err = db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, path_json, timestamp) VALUES (?, 'obs-hash', 'Observer', '[]', ?)`, id, ts.Unix())
}
if err != nil {
t.Fatal(err)
}
}
check := func(source string, messages []map[string]interface{}) {
t.Helper()
if len(messages) != 3 {
t.Fatalf("%s: got %d messages, want 3", source, len(messages))
}
for _, message := range messages {
hash, _ := message["packetHash"].(string)
var n int
if _, err := fmt.Sscanf(hash, "sender-size-%d", &n); err != nil {
t.Fatalf("%s: unexpected hash %q", source, hash)
}
want := []int{2, 3, 0}[n]
if got := message["senderPathHashSize"]; got != want {
t.Errorf("%s %s: senderPathHashSize=%v, want %d", source, hash, got, want)
}
}
}
messages, _, err := db.GetChannelMessages("#sender-size", 100, 0)
if err != nil {
t.Fatal(err)
}
check("db", messages)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatal(err)
}
messages, _ = store.GetChannelMessages("#sender-size", 100, 0)
check("store", messages)
})
}
}
10 changes: 6 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/meshcore-analyzer/dbschema"
"github.com/meshcore-analyzer/geofilter"
"github.com/meshcore-analyzer/packetpath"
regionutil "github.com/meshcore-analyzer/regions"
_ "modernc.org/sqlite"
)
Expand Down Expand Up @@ -3579,15 +3580,15 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
}
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, t.route_type` + scopeCol + resolvedPathCol + `
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, t.route_type` + scopeCol + resolvedPathCol + `
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
Expand Down Expand Up @@ -3635,12 +3636,12 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .

for rows.Next() {
var pktID, txID int
var pktHash, dj, fs, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString
var pktHash, dj, fs, rawHexHead, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString
var snr sql.NullFloat64
var obsTs sql.NullInt64
var routeType sql.NullInt64
var scopeName sql.NullString
scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs, &routeType}
scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &rawHexHead, &obsID, &obsName, &snr, &pathJSON, &obsTs, &routeType}
if db.hasScopeName() {
scanArgs = append(scanArgs, &scopeName)
}
Expand Down Expand Up @@ -3754,6 +3755,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
"entryPrefix": entryPrefix,
"entryObserverPubkey": entryObserverPubkey,
"observedPathHashSizes": observedPathHashSizes(pathHashSizeMask),
"senderPathHashSize": packetpath.SenderHashSize(rawHexHead.String),
},
Repeats: 1,
PathHashSizeMask: pathHashSizeMask,
Expand Down
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 @@ -6391,6 +6392,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
"routeType": intPtrOrNil(tx.RouteType),
"entryPrefix": pathFirstHop(tx.PathJSON),
"observedPathHashSizes": tx.observedPathHashSizes(),
"senderPathHashSize": packetpath.SenderHashSize(tx.RawHex),
},
Repeats: 1,
Observers: observers,
Expand Down
9 changes: 8 additions & 1 deletion docs/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,8 @@ Messages for a specific channel.
"observers": [string], // observer names
"hops": number,
"snr": number | null,
"observedPathHashSizes": [number] // sorted unique relayed path widths (1–3)
"observedPathHashSizes": [number], // sorted unique relayed path widths (1–3)
"senderPathHashSize": number // 0 if unknown, otherwise header width (1–3)
}
],
"total": number // total deduplicated messages
Expand All @@ -1422,6 +1423,12 @@ one value means different widths were observed for the same deduplicated
message; the field describes those observations, not the sender's permanent
configuration.

`senderPathHashSize` is read from the transmission's raw frame header, not
inferred from its observations. A flood can encode this width even when no
relay has forwarded it. A direct zero-hop marker, unsupported payload header,
or malformed frame yields 0 (unknown). This value describes that particular
frame, not the sender's permanent configuration.

---

## Shared channel proposals
Expand Down
30 changes: 30 additions & 0 deletions internal/packetpath/hash_size_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package packetpath

import "testing"

func TestSenderHashSize(t *testing.T) {
for _, tc := range []struct {
name, raw string
want int
}{
{"flood one byte", "1502AABBDEADBEEF", 1},
{"flood two bytes at zero hops", "1540DEADBEEF", 2},
{"flood three bytes at zero hops", "1580DEADBEEF", 3},
{"transport flood", "141122334480DEADBEEF", 3},
{"direct with path", "1641AABBDEADBEEF", 2},
{"direct zero hop", "1600DEADBEEF", 0},
{"transport direct zero hop", "171122334400DEADBEEF", 0},
{"trace header is SNR", "2542AABBDEADBEEF", 0},
{"reserved size", "15C0DEADBEEF", 0},
{"missing path byte", "15", 0},
{"truncated transport header", "141122", 0},
{"bad hex", "ZZ40", 0},
{"empty", "", 0},
} {
t.Run(tc.name, func(t *testing.T) {
if got := SenderHashSize(tc.raw); got != tc.want {
t.Errorf("SenderHashSize(%q) = %d, want %d", tc.raw, got, tc.want)
}
})
}
}
36 changes: 36 additions & 0 deletions internal/packetpath/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,42 @@ func DecodePathFromRawHex(rawHex string) ([]string, error) {
return hops, nil
}

// SenderHashSize reads the width chosen for this frame from the path byte.
// Floods encode the width even with zero relay hops. A direct 0x00 path byte
// is sendZeroHop's marker and carries no width; TRACE path bytes are SNR.
// Zero means the width cannot be established from the supplied header.
func SenderHashSize(rawHex string) int {
header, ok := pathHeaderByte(rawHex, 0)
if !ok || !PathBytesAreHops(header>>2&0x0f) {
return 0
}
route := RouteTypeFromHeader(header)
offset := 1
if IsTransportRoute(route) {
offset += 4
}
pathByte, ok := pathHeaderByte(rawHex, offset)
if !ok || pathByte>>6 == 3 {
return 0
}
if pathByte == 0 && (route == RouteDirect || route == RouteTransportDirect) {
return 0
}
return int(pathByte>>6) + 1
}

func pathHeaderByte(rawHex string, offset int) (byte, bool) {
start := offset * 2
if len(rawHex) < start+2 {
return 0, false
}
var b [1]byte
if _, err := hex.Decode(b[:], []byte(rawHex[start:start+2])); 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
14 changes: 14 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ 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; }
/** Sender-selected path-hash width in this frame, or null when not encoded. */
function senderPathHashSize(rawHex) {
if (typeof rawHex !== 'string' || !/^[0-9a-f]{2}/i.test(rawHex)) return null;
const header = parseInt(rawHex.slice(0, 2), 16);
if (((header >> 2) & 0x0F) === 9) return null; // TRACE path bytes are SNR
const route = header & 0x03;
const offset = getPathLenOffset(route) * 2;
const pathHex = rawHex.slice(offset, offset + 2);
if (!/^[0-9a-f]{2}$/i.test(pathHex)) return null;
const pathByte = parseInt(pathHex, 16);
if (pathByte === 0 && (route === 2 || route === 3)) return null;
const size = (pathByte >> 6) + 1;
return size <= 3 ? size : null;
}
/**
* scopeName is optional (callers that don't pass it get the original
* unscoped "T" badge). Pass a packet's scope_name to also surface the
Expand Down
Loading
Loading