diff --git a/Dockerfile b/Dockerfile index f96617f0..b5e91635 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ ARG BUILD_TIME=unknown # Build server WORKDIR /build/server COPY cmd/server/go.mod cmd/server/go.sum ./ +COPY go.mod /build/go.mod +COPY internal/decoder/ /build/internal/decoder/ RUN go mod download COPY cmd/server/ ./ RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server . @@ -16,6 +18,8 @@ RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMI # Build ingestor WORKDIR /build/ingestor COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./ +COPY go.mod /build/go.mod +COPY internal/decoder/ /build/internal/decoder/ RUN go mod download COPY cmd/ingestor/ ./ RUN go build -o /corescope-ingestor . diff --git a/Dockerfile.go b/Dockerfile.go index 6819e579..758bff02 100644 --- a/Dockerfile.go +++ b/Dockerfile.go @@ -7,14 +7,20 @@ ARG GIT_COMMIT=unknown ARG BUILD_TIME=unknown # Build server -WORKDIR /build/server +WORKDIR /build +COPY go.mod ./go.mod +COPY internal/decoder/ ./internal/decoder/ +WORKDIR /build/cmd/server COPY cmd/server/go.mod cmd/server/go.sum ./ RUN go mod download COPY cmd/server/ ./ RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server . # Build ingestor -WORKDIR /build/ingestor +WORKDIR /build +COPY go.mod ./go.mod +COPY internal/decoder/ ./internal/decoder/ +WORKDIR /build/cmd/ingestor COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./ RUN go mod download COPY cmd/ingestor/ ./ diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index 78cc4f71..3ce96cc4 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -1,739 +1,55 @@ -package main - -import ( - "crypto/aes" - "crypto/hmac" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "strings" - "unicode/utf8" -) - -// Route type constants (header bits 1-0) -const ( - RouteTransportFlood = 0 - RouteFlood = 1 - RouteDirect = 2 - RouteTransportDirect = 3 -) - -// Payload type constants (header bits 5-2) -const ( - PayloadREQ = 0x00 - PayloadRESPONSE = 0x01 - PayloadTXT_MSG = 0x02 - PayloadACK = 0x03 - PayloadADVERT = 0x04 - PayloadGRP_TXT = 0x05 - PayloadGRP_DATA = 0x06 - PayloadANON_REQ = 0x07 - PayloadPATH = 0x08 - PayloadTRACE = 0x09 - PayloadMULTIPART = 0x0A - PayloadCONTROL = 0x0B - PayloadRAW_CUSTOM = 0x0F -) - -var routeTypeNames = map[int]string{ - 0: "TRANSPORT_FLOOD", - 1: "FLOOD", - 2: "DIRECT", - 3: "TRANSPORT_DIRECT", -} - -var payloadTypeNames = map[int]string{ - 0x00: "REQ", - 0x01: "RESPONSE", - 0x02: "TXT_MSG", - 0x03: "ACK", - 0x04: "ADVERT", - 0x05: "GRP_TXT", - 0x06: "GRP_DATA", - 0x07: "ANON_REQ", - 0x08: "PATH", - 0x09: "TRACE", - 0x0A: "MULTIPART", - 0x0B: "CONTROL", - 0x0F: "RAW_CUSTOM", -} - -// Header is the decoded packet header. -type Header struct { - RouteType int `json:"routeType"` - RouteTypeName string `json:"routeTypeName"` - PayloadType int `json:"payloadType"` - PayloadTypeName string `json:"payloadTypeName"` - PayloadVersion int `json:"payloadVersion"` -} - -// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes. -type TransportCodes struct { - Code1 string `json:"code1"` - Code2 string `json:"code2"` -} - -// Path holds decoded path/hop information. -type Path struct { - HashSize int `json:"hashSize"` - HashCount int `json:"hashCount"` - Hops []string `json:"hops"` -} - -// AdvertFlags holds decoded advert flag bits. -type AdvertFlags struct { - Raw int `json:"raw"` - Type int `json:"type"` - Chat bool `json:"chat"` - Repeater bool `json:"repeater"` - Room bool `json:"room"` - Sensor bool `json:"sensor"` - HasLocation bool `json:"hasLocation"` - HasFeat1 bool `json:"hasFeat1"` - HasFeat2 bool `json:"hasFeat2"` - HasName bool `json:"hasName"` -} - -// Payload is a generic decoded payload. Fields are populated depending on type. -type Payload struct { - Type string `json:"type"` - DestHash string `json:"destHash,omitempty"` - SrcHash string `json:"srcHash,omitempty"` - MAC string `json:"mac,omitempty"` - EncryptedData string `json:"encryptedData,omitempty"` - ExtraHash string `json:"extraHash,omitempty"` - PubKey string `json:"pubKey,omitempty"` - Timestamp uint32 `json:"timestamp,omitempty"` - TimestampISO string `json:"timestampISO,omitempty"` - Signature string `json:"signature,omitempty"` - Flags *AdvertFlags `json:"flags,omitempty"` - Lat *float64 `json:"lat,omitempty"` - Lon *float64 `json:"lon,omitempty"` - Name string `json:"name,omitempty"` - Feat1 *int `json:"feat1,omitempty"` - Feat2 *int `json:"feat2,omitempty"` - BatteryMv *int `json:"battery_mv,omitempty"` - TemperatureC *float64 `json:"temperature_c,omitempty"` - ChannelHash int `json:"channelHash,omitempty"` - ChannelHashHex string `json:"channelHashHex,omitempty"` - DecryptionStatus string `json:"decryptionStatus,omitempty"` - Channel string `json:"channel,omitempty"` - Text string `json:"text,omitempty"` - Sender string `json:"sender,omitempty"` - SenderTimestamp uint32 `json:"sender_timestamp,omitempty"` - EphemeralPubKey string `json:"ephemeralPubKey,omitempty"` - PathData string `json:"pathData,omitempty"` - Tag uint32 `json:"tag,omitempty"` - AuthCode uint32 `json:"authCode,omitempty"` - TraceFlags *int `json:"traceFlags,omitempty"` - RawHex string `json:"raw,omitempty"` - Error string `json:"error,omitempty"` -} - -// DecodedPacket is the full decoded result. -type DecodedPacket struct { - Header Header `json:"header"` - TransportCodes *TransportCodes `json:"transportCodes"` - Path Path `json:"path"` - Payload Payload `json:"payload"` - Raw string `json:"raw"` -} - -func decodeHeader(b byte) Header { - rt := int(b & 0x03) - pt := int((b >> 2) & 0x0F) - pv := int((b >> 6) & 0x03) - - rtName := routeTypeNames[rt] - if rtName == "" { - rtName = "UNKNOWN" - } - ptName := payloadTypeNames[pt] - if ptName == "" { - ptName = "UNKNOWN" - } - - return Header{ - RouteType: rt, - RouteTypeName: rtName, - PayloadType: pt, - PayloadTypeName: ptName, - PayloadVersion: pv, - } -} - -func decodePath(pathByte byte, buf []byte, offset int) (Path, int) { - hashSize := int(pathByte>>6) + 1 - hashCount := int(pathByte & 0x3F) - totalBytes := hashSize * hashCount - hops := make([]string, 0, hashCount) - - for i := 0; i < hashCount; i++ { - start := offset + i*hashSize - end := start + hashSize - if end > len(buf) { - break - } - hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end]))) - } - - return Path{ - HashSize: hashSize, - HashCount: hashCount, - Hops: hops, - }, totalBytes -} - -func isTransportRoute(routeType int) bool { - return routeType == RouteTransportFlood || routeType == RouteTransportDirect -} - -func decodeEncryptedPayload(typeName string, buf []byte) Payload { - if len(buf) < 4 { - return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)} - } - return Payload{ - Type: typeName, - DestHash: hex.EncodeToString(buf[0:1]), - SrcHash: hex.EncodeToString(buf[1:2]), - MAC: hex.EncodeToString(buf[2:4]), - EncryptedData: hex.EncodeToString(buf[4:]), - } -} - -func decodeAck(buf []byte) Payload { - if len(buf) < 4 { - return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - checksum := binary.LittleEndian.Uint32(buf[0:4]) - return Payload{ - Type: "ACK", - ExtraHash: fmt.Sprintf("%08x", checksum), - } -} - -func decodeAdvert(buf []byte) Payload { - if len(buf) < 100 { - return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)} - } - - pubKey := hex.EncodeToString(buf[0:32]) - timestamp := binary.LittleEndian.Uint32(buf[32:36]) - signature := hex.EncodeToString(buf[36:100]) - appdata := buf[100:] - - p := Payload{ - Type: "ADVERT", - PubKey: pubKey, - Timestamp: timestamp, - TimestampISO: fmt.Sprintf("%s", epochToISO(timestamp)), - Signature: signature, - } - - if len(appdata) > 0 { - flags := appdata[0] - advType := int(flags & 0x0F) - hasFeat1 := flags&0x20 != 0 - hasFeat2 := flags&0x40 != 0 - p.Flags = &AdvertFlags{ - Raw: int(flags), - Type: advType, - Chat: advType == 1, - Repeater: advType == 2, - Room: advType == 3, - Sensor: advType == 4, - HasLocation: flags&0x10 != 0, - HasFeat1: hasFeat1, - HasFeat2: hasFeat2, - HasName: flags&0x80 != 0, - } - - off := 1 - if p.Flags.HasLocation && len(appdata) >= off+8 { - latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4])) - lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8])) - lat := float64(latRaw) / 1e6 - lon := float64(lonRaw) / 1e6 - p.Lat = &lat - p.Lon = &lon - off += 8 - } - if hasFeat1 && len(appdata) >= off+2 { - feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2])) - p.Feat1 = &feat1 - off += 2 - } - if hasFeat2 && len(appdata) >= off+2 { - feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2])) - p.Feat2 = &feat2 - off += 2 - } - if p.Flags.HasName { - // Find null terminator to separate name from trailing telemetry bytes - nameEnd := len(appdata) - for i := off; i < len(appdata); i++ { - if appdata[i] == 0x00 { - nameEnd = i - break - } - } - name := string(appdata[off:nameEnd]) - name = sanitizeName(name) - p.Name = name - off = nameEnd - // Skip null terminator(s) - for off < len(appdata) && appdata[off] == 0x00 { - off++ - } - } - - // Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100) - // Only sensor nodes (advType=4) carry telemetry bytes. - if p.Flags.Sensor && off+4 <= len(appdata) { - batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2])) - tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4])) - tempC := float64(tempRaw) / 100.0 - if batteryMv > 0 && batteryMv <= 10000 { - p.BatteryMv = &batteryMv - } - // Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000) - if tempRaw >= -5000 && tempRaw <= 10000 { - p.TemperatureC = &tempC - } - } - } - - return p -} - -// channelDecryptResult holds the decrypted channel message fields. -type channelDecryptResult struct { - Timestamp uint32 - Flags byte - Sender string - Message string -} - -// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t). -func countNonPrintable(s string) int { - count := 0 - for _, r := range s { - if r < 0x20 && r != '\n' && r != '\t' { - count++ - } else if r == utf8.RuneError { - count++ - } - } - return count -} - -// decryptChannelMessage implements MeshCore channel decryption: -// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption. -func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) { - channelKey, err := hex.DecodeString(channelKeyHex) - if err != nil || len(channelKey) != 16 { - return nil, fmt.Errorf("invalid channel key") - } - - macBytes, err := hex.DecodeString(macHex) - if err != nil || len(macBytes) != 2 { - return nil, fmt.Errorf("invalid MAC") - } - - ciphertext, err := hex.DecodeString(ciphertextHex) - if err != nil || len(ciphertext) == 0 { - return nil, fmt.Errorf("invalid ciphertext") - } - - // 32-byte channel secret: 16-byte key + 16 zero bytes - channelSecret := make([]byte, 32) - copy(channelSecret, channelKey) - - // Verify HMAC-SHA256 (first 2 bytes must match provided MAC) - h := hmac.New(sha256.New, channelSecret) - h.Write(ciphertext) - calculatedMac := h.Sum(nil) - if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] { - return nil, fmt.Errorf("MAC verification failed") - } - - // AES-128-ECB decrypt (block-by-block, no padding) - if len(ciphertext)%aes.BlockSize != 0 { - return nil, fmt.Errorf("ciphertext not aligned to AES block size") - } - block, err := aes.NewCipher(channelKey) - if err != nil { - return nil, fmt.Errorf("AES cipher: %w", err) - } - plaintext := make([]byte, len(ciphertext)) - for i := 0; i < len(ciphertext); i += aes.BlockSize { - block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) - } - - // Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated) - if len(plaintext) < 5 { - return nil, fmt.Errorf("decrypted content too short") - } - timestamp := binary.LittleEndian.Uint32(plaintext[0:4]) - flags := plaintext[4] - messageText := string(plaintext[5:]) - if idx := strings.IndexByte(messageText, 0); idx >= 0 { - messageText = messageText[:idx] - } - - // Validate decrypted text is printable UTF-8 (not binary garbage) - if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 { - return nil, fmt.Errorf("decrypted text contains non-printable characters") - } - - result := &channelDecryptResult{Timestamp: timestamp, Flags: flags} - - // Parse "sender: message" format - colonIdx := strings.Index(messageText, ": ") - if colonIdx > 0 && colonIdx < 50 { - potentialSender := messageText[:colonIdx] - if !strings.ContainsAny(potentialSender, ":[]") { - result.Sender = potentialSender - result.Message = messageText[colonIdx+2:] - } else { - result.Message = messageText - } - } else { - result.Message = messageText - } - - return result, nil -} - -func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload { - if len(buf) < 3 { - return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - - channelHash := int(buf[0]) - channelHashHex := fmt.Sprintf("%02X", buf[0]) - mac := hex.EncodeToString(buf[1:3]) - encryptedData := hex.EncodeToString(buf[3:]) - - hasKeys := len(channelKeys) > 0 - // Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars) - if hasKeys && len(encryptedData) >= 10 { - for name, key := range channelKeys { - result, err := decryptChannelMessage(encryptedData, mac, key) - if err != nil { - continue - } - text := result.Message - if result.Sender != "" && result.Message != "" { - text = result.Sender + ": " + result.Message - } - return Payload{ - Type: "CHAN", - Channel: name, - ChannelHash: channelHash, - ChannelHashHex: channelHashHex, - DecryptionStatus: "decrypted", - Sender: result.Sender, - Text: text, - SenderTimestamp: result.Timestamp, - } - } - return Payload{ - Type: "GRP_TXT", - ChannelHash: channelHash, - ChannelHashHex: channelHashHex, - DecryptionStatus: "decryption_failed", - MAC: mac, - EncryptedData: encryptedData, - } - } - - return Payload{ - Type: "GRP_TXT", - ChannelHash: channelHash, - ChannelHashHex: channelHashHex, - DecryptionStatus: "no_key", - MAC: mac, - EncryptedData: encryptedData, - } -} - -func decodeAnonReq(buf []byte) Payload { - if len(buf) < 35 { - return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - return Payload{ - Type: "ANON_REQ", - DestHash: hex.EncodeToString(buf[0:1]), - EphemeralPubKey: hex.EncodeToString(buf[1:33]), - MAC: hex.EncodeToString(buf[33:35]), - EncryptedData: hex.EncodeToString(buf[35:]), - } -} - -func decodePathPayload(buf []byte) Payload { - if len(buf) < 4 { - return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - return Payload{ - Type: "PATH", - DestHash: hex.EncodeToString(buf[0:1]), - SrcHash: hex.EncodeToString(buf[1:2]), - MAC: hex.EncodeToString(buf[2:4]), - PathData: hex.EncodeToString(buf[4:]), - } -} - -func decodeTrace(buf []byte) Payload { - if len(buf) < 9 { - return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - tag := binary.LittleEndian.Uint32(buf[0:4]) - authCode := binary.LittleEndian.Uint32(buf[4:8]) - flags := int(buf[8]) - p := Payload{ - Type: "TRACE", - Tag: tag, - AuthCode: authCode, - TraceFlags: &flags, - } - if len(buf) > 9 { - p.PathData = hex.EncodeToString(buf[9:]) - } - return p -} - -func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload { - switch payloadType { - case PayloadREQ: - return decodeEncryptedPayload("REQ", buf) - case PayloadRESPONSE: - return decodeEncryptedPayload("RESPONSE", buf) - case PayloadTXT_MSG: - return decodeEncryptedPayload("TXT_MSG", buf) - case PayloadACK: - return decodeAck(buf) - case PayloadADVERT: - return decodeAdvert(buf) - case PayloadGRP_TXT: - return decodeGrpTxt(buf, channelKeys) - case PayloadANON_REQ: - return decodeAnonReq(buf) - case PayloadPATH: - return decodePathPayload(buf) - case PayloadTRACE: - return decodeTrace(buf) - default: - return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)} - } -} - -// DecodePacket decodes a hex-encoded MeshCore packet. -func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) { - hexString = strings.ReplaceAll(hexString, " ", "") - hexString = strings.ReplaceAll(hexString, "\n", "") - hexString = strings.ReplaceAll(hexString, "\r", "") - - buf, err := hex.DecodeString(hexString) - if err != nil { - return nil, fmt.Errorf("invalid hex: %w", err) - } - if len(buf) < 2 { - return nil, fmt.Errorf("packet too short (need at least header + pathLength)") - } - - header := decodeHeader(buf[0]) - offset := 1 - - var tc *TransportCodes - if isTransportRoute(header.RouteType) { - if len(buf) < offset+4 { - return nil, fmt.Errorf("packet too short for transport codes") - } - tc = &TransportCodes{ - Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])), - Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])), - } - offset += 4 - } - - if offset >= len(buf) { - return nil, fmt.Errorf("packet too short (no path byte)") - } - pathByte := buf[offset] - offset++ - - path, bytesConsumed := decodePath(pathByte, buf, offset) - offset += bytesConsumed - - payloadBuf := buf[offset:] - payload := decodePayload(header.PayloadType, payloadBuf, channelKeys) - - // TRACE packets store hop IDs in the payload (buf[9:]) rather than the header - // path field. The header path byte still encodes hashSize in bits 6-7, which - // we use to split the payload path data into individual hop prefixes. - if header.PayloadType == PayloadTRACE && payload.PathData != "" { - pathBytes, err := hex.DecodeString(payload.PathData) - if err == nil && path.HashSize > 0 { - hops := make([]string, 0, len(pathBytes)/path.HashSize) - for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize { - hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize]))) - } - path.Hops = hops - path.HashCount = len(hops) - } - } - - return &DecodedPacket{ - Header: header, - TransportCodes: tc, - Path: path, - Payload: payload, - Raw: strings.ToUpper(hexString), - }, nil -} - -// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars). -// It hashes the header byte + payload (skipping path bytes) to produce a -// path-independent identifier for the same transmission. -func ComputeContentHash(rawHex string) string { - buf, err := hex.DecodeString(rawHex) - if err != nil || len(buf) < 2 { - if len(rawHex) >= 16 { - return rawHex[:16] - } - return rawHex - } - - headerByte := buf[0] - offset := 1 - if isTransportRoute(int(headerByte & 0x03)) { - offset += 4 - } - if offset >= len(buf) { - if len(rawHex) >= 16 { - return rawHex[:16] - } - return rawHex - } - pathByte := buf[offset] - offset++ - hashSize := int((pathByte>>6)&0x3) + 1 - hashCount := int(pathByte & 0x3F) - pathBytes := hashSize * hashCount - - payloadStart := offset + pathBytes - if payloadStart > len(buf) { - if len(rawHex) >= 16 { - return rawHex[:16] - } - return rawHex - } - - payload := buf[payloadStart:] - toHash := append([]byte{headerByte}, payload...) - - h := sha256.Sum256(toHash) - return hex.EncodeToString(h[:])[:16] -} - -// PayloadJSON serializes the payload to JSON for DB storage. -func PayloadJSON(p *Payload) string { - b, err := json.Marshal(p) - if err != nil { - return "{}" - } - return string(b) -} - -// ValidateAdvert checks decoded advert data before DB insertion. -func ValidateAdvert(p *Payload) (bool, string) { - if p == nil || p.Error != "" { - reason := "null advert" - if p != nil { - reason = p.Error - } - return false, reason - } - - pk := p.PubKey - if len(pk) < 16 { - return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk)) - } - allZero := true - for _, c := range pk { - if c != '0' { - allZero = false - break - } - } - if allZero { - return false, "pubkey is all zeros" - } - - if p.Lat != nil { - if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 { - return false, fmt.Sprintf("invalid lat: %f", *p.Lat) - } - } - if p.Lon != nil { - if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 { - return false, fmt.Sprintf("invalid lon: %f", *p.Lon) - } - } - - if p.Name != "" { - for _, c := range p.Name { - if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f { - return false, "name contains control characters" - } - } - if len(p.Name) > 64 { - return false, fmt.Sprintf("name too long (%d chars)", len(p.Name)) - } - } - - if p.Flags != nil { - role := advertRole(p.Flags) - validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true} - if !validRoles[role] { - return false, fmt.Sprintf("unknown role: %s", role) - } - } - - return true, "" -} - -// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL. -func sanitizeName(s string) string { - var b strings.Builder - b.Grow(len(s)) - for _, c := range s { - if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) { - b.WriteRune(c) - } - } - return b.String() -} - -func advertRole(f *AdvertFlags) string { - if f.Repeater { - return "repeater" - } - if f.Room { - return "room" - } - if f.Sensor { - return "sensor" - } - return "companion" -} - -func epochToISO(epoch uint32) string { - // Go time from Unix epoch - t := unixTime(int64(epoch)) - return t.UTC().Format("2006-01-02T15:04:05.000Z") -} +package main + +import dec "github.com/corescope/internal/decoder" + +const ( + RouteTransportFlood = dec.RouteTransportFlood + RouteFlood = dec.RouteFlood + RouteDirect = dec.RouteDirect + RouteTransportDirect = dec.RouteTransportDirect + + PayloadREQ = dec.PayloadREQ + PayloadRESPONSE = dec.PayloadRESPONSE + PayloadTXT_MSG = dec.PayloadTXT_MSG + PayloadACK = dec.PayloadACK + PayloadADVERT = dec.PayloadADVERT + PayloadGRP_TXT = dec.PayloadGRP_TXT + PayloadGRP_DATA = dec.PayloadGRP_DATA + PayloadANON_REQ = dec.PayloadANON_REQ + PayloadPATH = dec.PayloadPATH + PayloadTRACE = dec.PayloadTRACE + PayloadMULTIPART = dec.PayloadMULTIPART + PayloadCONTROL = dec.PayloadCONTROL + PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM +) + +type Header = dec.Header +type TransportCodes = dec.TransportCodes +type Path = dec.Path +type AdvertFlags = dec.AdvertFlags +type Payload = dec.Payload +type DecodedPacket = dec.DecodedPacket + +func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) { + return dec.DecodePacket(hexString, channelKeys) +} + +func ComputeContentHash(rawHex string) string { + return dec.ComputeContentHash(rawHex) +} + +func PayloadJSON(p *Payload) string { + return dec.PayloadJSON(p) +} + +func ValidateAdvert(p *Payload) (bool, string) { + return dec.ValidateAdvert(p) +} + +func advertRole(f *AdvertFlags) string { + return dec.AdvertRole(f) +} + +func epochToISO(epoch uint32) string { + return dec.EpochToISO(epoch) +} diff --git a/cmd/ingestor/go.mod b/cmd/ingestor/go.mod index cc2098e7..038cbada 100644 --- a/cmd/ingestor/go.mod +++ b/cmd/ingestor/go.mod @@ -3,6 +3,7 @@ module github.com/corescope/ingestor go 1.22 require ( + github.com/corescope v0.0.0 github.com/eclipse/paho.mqtt.golang v1.5.0 modernc.org/sqlite v1.34.5 ) @@ -21,3 +22,5 @@ require ( modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect ) + +replace github.com/corescope => ../.. diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index c67fa9e2..c017a562 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -1,537 +1,47 @@ -package main - -import ( - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "strings" - "time" -) - -// Route type constants (header bits 1-0) -const ( - RouteTransportFlood = 0 - RouteFlood = 1 - RouteDirect = 2 - RouteTransportDirect = 3 -) - -// Payload type constants (header bits 5-2) -const ( - PayloadREQ = 0x00 - PayloadRESPONSE = 0x01 - PayloadTXT_MSG = 0x02 - PayloadACK = 0x03 - PayloadADVERT = 0x04 - PayloadGRP_TXT = 0x05 - PayloadGRP_DATA = 0x06 - PayloadANON_REQ = 0x07 - PayloadPATH = 0x08 - PayloadTRACE = 0x09 - PayloadMULTIPART = 0x0A - PayloadCONTROL = 0x0B - PayloadRAW_CUSTOM = 0x0F -) - -var routeTypeNames = map[int]string{ - 0: "TRANSPORT_FLOOD", - 1: "FLOOD", - 2: "DIRECT", - 3: "TRANSPORT_DIRECT", -} - -// Header is the decoded packet header. -type Header struct { - RouteType int `json:"routeType"` - RouteTypeName string `json:"routeTypeName"` - PayloadType int `json:"payloadType"` - PayloadTypeName string `json:"payloadTypeName"` - PayloadVersion int `json:"payloadVersion"` -} - -// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes. -type TransportCodes struct { - Code1 string `json:"code1"` - Code2 string `json:"code2"` -} - -// Path holds decoded path/hop information. -type Path struct { - HashSize int `json:"hashSize"` - HashCount int `json:"hashCount"` - Hops []string `json:"hops"` -} - -// AdvertFlags holds decoded advert flag bits. -type AdvertFlags struct { - Raw int `json:"raw"` - Type int `json:"type"` - Chat bool `json:"chat"` - Repeater bool `json:"repeater"` - Room bool `json:"room"` - Sensor bool `json:"sensor"` - HasLocation bool `json:"hasLocation"` - HasFeat1 bool `json:"hasFeat1"` - HasFeat2 bool `json:"hasFeat2"` - HasName bool `json:"hasName"` -} - -// Payload is a generic decoded payload. Fields are populated depending on type. -type Payload struct { - Type string `json:"type"` - DestHash string `json:"destHash,omitempty"` - SrcHash string `json:"srcHash,omitempty"` - MAC string `json:"mac,omitempty"` - EncryptedData string `json:"encryptedData,omitempty"` - ExtraHash string `json:"extraHash,omitempty"` - PubKey string `json:"pubKey,omitempty"` - Timestamp uint32 `json:"timestamp,omitempty"` - TimestampISO string `json:"timestampISO,omitempty"` - Signature string `json:"signature,omitempty"` - Flags *AdvertFlags `json:"flags,omitempty"` - Lat *float64 `json:"lat,omitempty"` - Lon *float64 `json:"lon,omitempty"` - Name string `json:"name,omitempty"` - ChannelHash int `json:"channelHash,omitempty"` - EphemeralPubKey string `json:"ephemeralPubKey,omitempty"` - PathData string `json:"pathData,omitempty"` - Tag uint32 `json:"tag,omitempty"` - AuthCode uint32 `json:"authCode,omitempty"` - TraceFlags *int `json:"traceFlags,omitempty"` - RawHex string `json:"raw,omitempty"` - Error string `json:"error,omitempty"` -} - -// DecodedPacket is the full decoded result. -type DecodedPacket struct { - Header Header `json:"header"` - TransportCodes *TransportCodes `json:"transportCodes"` - Path Path `json:"path"` - Payload Payload `json:"payload"` - Raw string `json:"raw"` -} - -func decodeHeader(b byte) Header { - rt := int(b & 0x03) - pt := int((b >> 2) & 0x0F) - pv := int((b >> 6) & 0x03) - - rtName := routeTypeNames[rt] - if rtName == "" { - rtName = "UNKNOWN" - } - ptName := payloadTypeNames[pt] - if ptName == "" { - ptName = "UNKNOWN" - } - - return Header{ - RouteType: rt, - RouteTypeName: rtName, - PayloadType: pt, - PayloadTypeName: ptName, - PayloadVersion: pv, - } -} - -func decodePath(pathByte byte, buf []byte, offset int) (Path, int) { - hashSize := int(pathByte>>6) + 1 - hashCount := int(pathByte & 0x3F) - totalBytes := hashSize * hashCount - hops := make([]string, 0, hashCount) - - for i := 0; i < hashCount; i++ { - start := offset + i*hashSize - end := start + hashSize - if end > len(buf) { - break - } - hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end]))) - } - - return Path{ - HashSize: hashSize, - HashCount: hashCount, - Hops: hops, - }, totalBytes -} - -func isTransportRoute(routeType int) bool { - return routeType == RouteTransportFlood || routeType == RouteTransportDirect -} - -func decodeEncryptedPayload(typeName string, buf []byte) Payload { - if len(buf) < 4 { - return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)} - } - return Payload{ - Type: typeName, - DestHash: hex.EncodeToString(buf[0:1]), - SrcHash: hex.EncodeToString(buf[1:2]), - MAC: hex.EncodeToString(buf[2:4]), - EncryptedData: hex.EncodeToString(buf[4:]), - } -} - -func decodeAck(buf []byte) Payload { - if len(buf) < 4 { - return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - checksum := binary.LittleEndian.Uint32(buf[0:4]) - return Payload{ - Type: "ACK", - ExtraHash: fmt.Sprintf("%08x", checksum), - } -} - -func decodeAdvert(buf []byte) Payload { - if len(buf) < 100 { - return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)} - } - - pubKey := hex.EncodeToString(buf[0:32]) - timestamp := binary.LittleEndian.Uint32(buf[32:36]) - signature := hex.EncodeToString(buf[36:100]) - appdata := buf[100:] - - p := Payload{ - Type: "ADVERT", - PubKey: pubKey, - Timestamp: timestamp, - TimestampISO: fmt.Sprintf("%s", epochToISO(timestamp)), - Signature: signature, - } - - if len(appdata) > 0 { - flags := appdata[0] - advType := int(flags & 0x0F) - hasFeat1 := flags&0x20 != 0 - hasFeat2 := flags&0x40 != 0 - p.Flags = &AdvertFlags{ - Raw: int(flags), - Type: advType, - Chat: advType == 1, - Repeater: advType == 2, - Room: advType == 3, - Sensor: advType == 4, - HasLocation: flags&0x10 != 0, - HasFeat1: hasFeat1, - HasFeat2: hasFeat2, - HasName: flags&0x80 != 0, - } - - off := 1 - if p.Flags.HasLocation && len(appdata) >= off+8 { - latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4])) - lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8])) - lat := float64(latRaw) / 1e6 - lon := float64(lonRaw) / 1e6 - p.Lat = &lat - p.Lon = &lon - off += 8 - } - if hasFeat1 && len(appdata) >= off+2 { - off += 2 // skip feat1 bytes (reserved for future use) - } - if hasFeat2 && len(appdata) >= off+2 { - off += 2 // skip feat2 bytes (reserved for future use) - } - if p.Flags.HasName { - name := string(appdata[off:]) - name = strings.TrimRight(name, "\x00") - name = sanitizeName(name) - p.Name = name - } - } - - return p -} - -func decodeGrpTxt(buf []byte) Payload { - if len(buf) < 3 { - return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - return Payload{ - Type: "GRP_TXT", - ChannelHash: int(buf[0]), - MAC: hex.EncodeToString(buf[1:3]), - EncryptedData: hex.EncodeToString(buf[3:]), - } -} - -func decodeAnonReq(buf []byte) Payload { - if len(buf) < 35 { - return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - return Payload{ - Type: "ANON_REQ", - DestHash: hex.EncodeToString(buf[0:1]), - EphemeralPubKey: hex.EncodeToString(buf[1:33]), - MAC: hex.EncodeToString(buf[33:35]), - EncryptedData: hex.EncodeToString(buf[35:]), - } -} - -func decodePathPayload(buf []byte) Payload { - if len(buf) < 4 { - return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - return Payload{ - Type: "PATH", - DestHash: hex.EncodeToString(buf[0:1]), - SrcHash: hex.EncodeToString(buf[1:2]), - MAC: hex.EncodeToString(buf[2:4]), - PathData: hex.EncodeToString(buf[4:]), - } -} - -func decodeTrace(buf []byte) Payload { - if len(buf) < 9 { - return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)} - } - tag := binary.LittleEndian.Uint32(buf[0:4]) - authCode := binary.LittleEndian.Uint32(buf[4:8]) - flags := int(buf[8]) - p := Payload{ - Type: "TRACE", - Tag: tag, - AuthCode: authCode, - TraceFlags: &flags, - } - if len(buf) > 9 { - p.PathData = hex.EncodeToString(buf[9:]) - } - return p -} - -func decodePayload(payloadType int, buf []byte) Payload { - switch payloadType { - case PayloadREQ: - return decodeEncryptedPayload("REQ", buf) - case PayloadRESPONSE: - return decodeEncryptedPayload("RESPONSE", buf) - case PayloadTXT_MSG: - return decodeEncryptedPayload("TXT_MSG", buf) - case PayloadACK: - return decodeAck(buf) - case PayloadADVERT: - return decodeAdvert(buf) - case PayloadGRP_TXT: - return decodeGrpTxt(buf) - case PayloadANON_REQ: - return decodeAnonReq(buf) - case PayloadPATH: - return decodePathPayload(buf) - case PayloadTRACE: - return decodeTrace(buf) - default: - return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)} - } -} - -// DecodePacket decodes a hex-encoded MeshCore packet. -func DecodePacket(hexString string) (*DecodedPacket, error) { - hexString = strings.ReplaceAll(hexString, " ", "") - hexString = strings.ReplaceAll(hexString, "\n", "") - hexString = strings.ReplaceAll(hexString, "\r", "") - - buf, err := hex.DecodeString(hexString) - if err != nil { - return nil, fmt.Errorf("invalid hex: %w", err) - } - if len(buf) < 2 { - return nil, fmt.Errorf("packet too short (need at least header + pathLength)") - } - - header := decodeHeader(buf[0]) - offset := 1 - - var tc *TransportCodes - if isTransportRoute(header.RouteType) { - if len(buf) < offset+4 { - return nil, fmt.Errorf("packet too short for transport codes") - } - tc = &TransportCodes{ - Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])), - Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])), - } - offset += 4 - } - - if offset >= len(buf) { - return nil, fmt.Errorf("packet too short (no path byte)") - } - pathByte := buf[offset] - offset++ - - path, bytesConsumed := decodePath(pathByte, buf, offset) - offset += bytesConsumed - - payloadBuf := buf[offset:] - payload := decodePayload(header.PayloadType, payloadBuf) - - // TRACE packets store hop IDs in the payload (buf[9:]) rather than the header - // path field. The header path byte still encodes hashSize in bits 6-7, which - // we use to split the payload path data into individual hop prefixes. - if header.PayloadType == PayloadTRACE && payload.PathData != "" { - pathBytes, err := hex.DecodeString(payload.PathData) - if err == nil && path.HashSize > 0 { - hops := make([]string, 0, len(pathBytes)/path.HashSize) - for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize { - hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize]))) - } - path.Hops = hops - path.HashCount = len(hops) - } - } - - return &DecodedPacket{ - Header: header, - TransportCodes: tc, - Path: path, - Payload: payload, - Raw: strings.ToUpper(hexString), - }, nil -} - -// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars). -func ComputeContentHash(rawHex string) string { - buf, err := hex.DecodeString(rawHex) - if err != nil || len(buf) < 2 { - if len(rawHex) >= 16 { - return rawHex[:16] - } - return rawHex - } - - headerByte := buf[0] - offset := 1 - if isTransportRoute(int(headerByte & 0x03)) { - offset += 4 - } - if offset >= len(buf) { - if len(rawHex) >= 16 { - return rawHex[:16] - } - return rawHex - } - pathByte := buf[offset] - offset++ - hashSize := int((pathByte>>6)&0x3) + 1 - hashCount := int(pathByte & 0x3F) - pathBytes := hashSize * hashCount - - payloadStart := offset + pathBytes - if payloadStart > len(buf) { - if len(rawHex) >= 16 { - return rawHex[:16] - } - return rawHex - } - - payload := buf[payloadStart:] - toHash := append([]byte{headerByte}, payload...) - - h := sha256.Sum256(toHash) - return hex.EncodeToString(h[:])[:16] -} - -// PayloadJSON serializes the payload to JSON for DB storage. -func PayloadJSON(p *Payload) string { - b, err := json.Marshal(p) - if err != nil { - return "{}" - } - return string(b) -} - -// ValidateAdvert checks decoded advert data before DB insertion. -func ValidateAdvert(p *Payload) (bool, string) { - if p == nil || p.Error != "" { - reason := "null advert" - if p != nil { - reason = p.Error - } - return false, reason - } - - pk := p.PubKey - if len(pk) < 16 { - return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk)) - } - allZero := true - for _, c := range pk { - if c != '0' { - allZero = false - break - } - } - if allZero { - return false, "pubkey is all zeros" - } - - if p.Lat != nil { - if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 { - return false, fmt.Sprintf("invalid lat: %f", *p.Lat) - } - } - if p.Lon != nil { - if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 { - return false, fmt.Sprintf("invalid lon: %f", *p.Lon) - } - } - - if p.Name != "" { - for _, c := range p.Name { - if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f { - return false, "name contains control characters" - } - } - if len(p.Name) > 64 { - return false, fmt.Sprintf("name too long (%d chars)", len(p.Name)) - } - } - - if p.Flags != nil { - role := advertRole(p.Flags) - validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true} - if !validRoles[role] { - return false, fmt.Sprintf("unknown role: %s", role) - } - } - - return true, "" -} - -// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL. -func sanitizeName(s string) string { - var b strings.Builder - b.Grow(len(s)) - for _, c := range s { - if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) { - b.WriteRune(c) - } - } - return b.String() -} - -func advertRole(f *AdvertFlags) string { - if f.Repeater { - return "repeater" - } - if f.Room { - return "room" - } - if f.Sensor { - return "sensor" - } - return "companion" -} - -func epochToISO(epoch uint32) string { - t := time.Unix(int64(epoch), 0) - return t.UTC().Format("2006-01-02T15:04:05.000Z") -} +package main + +import dec "github.com/corescope/internal/decoder" + +const ( + RouteTransportFlood = dec.RouteTransportFlood + RouteFlood = dec.RouteFlood + RouteDirect = dec.RouteDirect + RouteTransportDirect = dec.RouteTransportDirect + + PayloadREQ = dec.PayloadREQ + PayloadRESPONSE = dec.PayloadRESPONSE + PayloadTXT_MSG = dec.PayloadTXT_MSG + PayloadACK = dec.PayloadACK + PayloadADVERT = dec.PayloadADVERT + PayloadGRP_TXT = dec.PayloadGRP_TXT + PayloadGRP_DATA = dec.PayloadGRP_DATA + PayloadANON_REQ = dec.PayloadANON_REQ + PayloadPATH = dec.PayloadPATH + PayloadTRACE = dec.PayloadTRACE + PayloadMULTIPART = dec.PayloadMULTIPART + PayloadCONTROL = dec.PayloadCONTROL + PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM +) + +type Header = dec.Header +type TransportCodes = dec.TransportCodes +type Path = dec.Path +type AdvertFlags = dec.AdvertFlags +type Payload = dec.Payload +type DecodedPacket = dec.DecodedPacket + +func DecodePacket(hexString string) (*DecodedPacket, error) { + return dec.DecodePacket(hexString, nil) +} + +func ComputeContentHash(rawHex string) string { + return dec.ComputeContentHash(rawHex) +} + +func PayloadJSON(p *Payload) string { + return dec.PayloadJSON(p) +} + +func ValidateAdvert(p *Payload) (bool, string) { + return dec.ValidateAdvert(p) +} diff --git a/cmd/server/go.mod b/cmd/server/go.mod index 1ef2c8af..109e0ad1 100644 --- a/cmd/server/go.mod +++ b/cmd/server/go.mod @@ -3,6 +3,7 @@ module github.com/corescope/server go 1.22 require ( + github.com/corescope v0.0.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 modernc.org/sqlite v1.34.5 @@ -19,3 +20,5 @@ require ( modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect ) + +replace github.com/corescope => ../.. diff --git a/cmd/server/routes.go b/cmd/server/routes.go index bee1d250..71bb72d0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -30,13 +30,13 @@ type Server struct { buildTime string // Cached runtime.MemStats to avoid stop-the-world pauses on every health check - memStatsMu sync.Mutex - memStatsCache runtime.MemStats + memStatsMu sync.Mutex + memStatsCache runtime.MemStats memStatsCachedAt time.Time // Cached /api/stats response — recomputed at most once every 10s - statsMu sync.Mutex - statsCache *StatsResponse + statsMu sync.Mutex + statsCache *StatsResponse statsCachedAt time.Time } @@ -117,7 +117,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.Handle("/api/packets", s.requireAPIKey(http.HandlerFunc(s.handlePostPacket))).Methods("POST") // Decode endpoint - r.Handle("/api/decode", s.requireAPIKey(http.HandlerFunc(s.handleDecode))).Methods("POST") + r.HandleFunc("/api/decode", s.handleDecode).Methods("POST") // Node endpoints — fixed routes BEFORE parameterized r.HandleFunc("/api/nodes/search", s.handleNodeSearch).Methods("GET") @@ -1181,7 +1181,7 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request return } writeJSON(w, map[string]interface{}{ - "total": 0, + "total": 0, "distribution": map[string]int{"1": 0, "2": 0, "3": 0}, "distributionByRepeaters": map[string]int{"1": 0, "2": 0, "3": 0}, "hourly": []HashSizeHourly{}, @@ -1352,12 +1352,12 @@ func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) { ID: o.ID, Name: o.Name, IATA: o.IATA, LastSeen: o.LastSeen, FirstSeen: o.FirstSeen, PacketCount: o.PacketCount, - Model: o.Model, Firmware: o.Firmware, + Model: o.Model, Firmware: o.Firmware, ClientVersion: o.ClientVersion, Radio: o.Radio, BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs, - NoiseFloor: o.NoiseFloor, + NoiseFloor: o.NoiseFloor, PacketsLastHour: plh, - Lat: lat, Lon: lon, NodeRole: nodeRole, + Lat: lat, Lon: lon, NodeRole: nodeRole, }) } writeJSON(w, ObserverListResponse{ @@ -1386,10 +1386,10 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) { ID: obs.ID, Name: obs.Name, IATA: obs.IATA, LastSeen: obs.LastSeen, FirstSeen: obs.FirstSeen, PacketCount: obs.PacketCount, - Model: obs.Model, Firmware: obs.Firmware, + Model: obs.Model, Firmware: obs.Firmware, ClientVersion: obs.ClientVersion, Radio: obs.Radio, BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs, - NoiseFloor: obs.NoiseFloor, + NoiseFloor: obs.NoiseFloor, PacketsLastHour: plh, }) } diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index dc4ec875..5fb77d34 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -48,7 +48,7 @@ func setupTestServerWithAPIKey(t *testing.T, apiKey string) (*Server, *mux.Route func TestWriteEndpointsRequireAPIKey(t *testing.T) { _, router := setupTestServerWithAPIKey(t, "test-secret") - t.Run("missing key returns 401", func(t *testing.T) { + t.Run("perf reset missing key returns 401", func(t *testing.T) { req := httptest.NewRequest("POST", "/api/perf/reset", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -62,10 +62,9 @@ func TestWriteEndpointsRequireAPIKey(t *testing.T) { } }) - t.Run("wrong key returns 401", func(t *testing.T) { - req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`)) + t.Run("packets post missing key returns 401", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/packets", bytes.NewBufferString(`{"raw":"0200"}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", "wrong-secret") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { @@ -73,10 +72,9 @@ func TestWriteEndpointsRequireAPIKey(t *testing.T) { } }) - t.Run("correct key passes", func(t *testing.T) { + t.Run("decode succeeds without key", func(t *testing.T) { req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", "test-secret") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { @@ -88,13 +86,34 @@ func TestWriteEndpointsRequireAPIKey(t *testing.T) { func TestWriteEndpointsBlockWhenAPIKeyEmpty(t *testing.T) { _, router := setupTestServerWithAPIKey(t, "") - req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusForbidden { - t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String()) - } + t.Run("perf reset blocked when api key unset", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/perf/reset", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String()) + } + }) + + t.Run("packets post blocked when api key unset", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/packets", bytes.NewBufferString(`{"raw":"0200"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String()) + } + }) + + t.Run("decode remains open when api key unset", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + }) } func TestHealthEndpoint(t *testing.T) { @@ -1622,7 +1641,6 @@ func TestHandlerErrorPaths(t *testing.T) { router := mux.NewRouter() srv.RegisterRoutes(router) - t.Run("stats error", func(t *testing.T) { db.conn.Exec("DROP TABLE IF EXISTS transmissions") req := httptest.NewRequest("GET", "/api/stats", nil) @@ -1843,240 +1861,239 @@ func TestHandlerErrorBulkHealth(t *testing.T) { } } - func TestAnalyticsChannelsNoNullArrays(t *testing.T) { -_, router := setupTestServer(t) -req := httptest.NewRequest("GET", "/api/analytics/channels", nil) -w := httptest.NewRecorder() -router.ServeHTTP(w, req) + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/channels", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) -if w.Code != 200 { -t.Fatalf("expected 200, got %d", w.Code) -} + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } -raw := w.Body.String() -var body map[string]interface{} -if err := json.Unmarshal([]byte(raw), &body); err != nil { -t.Fatalf("invalid JSON: %v", err) -} + raw := w.Body.String() + var body map[string]interface{} + if err := json.Unmarshal([]byte(raw), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } -arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"} -for _, field := range arrayFields { -val, exists := body[field] -if !exists { -t.Errorf("missing field %q", field) -continue -} -if val == nil { -t.Errorf("field %q is null, expected empty array []", field) -continue -} -if _, ok := val.([]interface{}); !ok { -t.Errorf("field %q is not an array, got %T", field, val) -} -} + arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"} + for _, field := range arrayFields { + val, exists := body[field] + if !exists { + t.Errorf("missing field %q", field) + continue + } + if val == nil { + t.Errorf("field %q is null, expected empty array []", field) + continue + } + if _, ok := val.([]interface{}); !ok { + t.Errorf("field %q is not an array, got %T", field, val) + } + } } func TestAnalyticsChannelsNoStoreFallbackNoNulls(t *testing.T) { -db := setupTestDB(t) -seedTestData(t, db) -cfg := &Config{Port: 3000} -hub := NewHub() -srv := NewServer(db, cfg, hub) -router := mux.NewRouter() -srv.RegisterRoutes(router) + db := setupTestDB(t) + seedTestData(t, db) + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) -req := httptest.NewRequest("GET", "/api/analytics/channels", nil) -w := httptest.NewRecorder() -router.ServeHTTP(w, req) + req := httptest.NewRequest("GET", "/api/analytics/channels", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) -if w.Code != 200 { -t.Fatalf("expected 200, got %d", w.Code) -} + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } -var body map[string]interface{} -json.Unmarshal(w.Body.Bytes(), &body) + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) -arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"} -for _, field := range arrayFields { -if body[field] == nil { -t.Errorf("field %q is null in DB fallback, expected []", field) -} -} + arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"} + for _, field := range arrayFields { + if body[field] == nil { + t.Errorf("field %q is null in DB fallback, expected []", field) + } + } } func TestNodeHashSizeEnrichment(t *testing.T) { -t.Run("nil info leaves defaults", func(t *testing.T) { -node := map[string]interface{}{ -"public_key": "abc123", -"hash_size": nil, -"hash_size_inconsistent": false, -} -EnrichNodeWithHashSize(node, nil) -if node["hash_size"] != nil { -t.Error("expected hash_size to remain nil with nil info") -} -}) + t.Run("nil info leaves defaults", func(t *testing.T) { + node := map[string]interface{}{ + "public_key": "abc123", + "hash_size": nil, + "hash_size_inconsistent": false, + } + EnrichNodeWithHashSize(node, nil) + if node["hash_size"] != nil { + t.Error("expected hash_size to remain nil with nil info") + } + }) -t.Run("enriches with computed data", func(t *testing.T) { -node := map[string]interface{}{ -"public_key": "abc123", -"hash_size": nil, -"hash_size_inconsistent": false, -} -info := &hashSizeNodeInfo{ -HashSize: 2, -AllSizes: map[int]bool{1: true, 2: true}, -Seq: []int{1, 2, 1, 2}, -Inconsistent: true, -} -EnrichNodeWithHashSize(node, info) -if node["hash_size"] != 2 { -t.Errorf("expected hash_size 2, got %v", node["hash_size"]) -} -if node["hash_size_inconsistent"] != true { -t.Error("expected hash_size_inconsistent true") -} -sizes, ok := node["hash_sizes_seen"].([]int) -if !ok { -t.Fatal("expected hash_sizes_seen to be []int") -} -if len(sizes) != 2 || sizes[0] != 1 || sizes[1] != 2 { -t.Errorf("expected [1,2], got %v", sizes) -} -}) + t.Run("enriches with computed data", func(t *testing.T) { + node := map[string]interface{}{ + "public_key": "abc123", + "hash_size": nil, + "hash_size_inconsistent": false, + } + info := &hashSizeNodeInfo{ + HashSize: 2, + AllSizes: map[int]bool{1: true, 2: true}, + Seq: []int{1, 2, 1, 2}, + Inconsistent: true, + } + EnrichNodeWithHashSize(node, info) + if node["hash_size"] != 2 { + t.Errorf("expected hash_size 2, got %v", node["hash_size"]) + } + if node["hash_size_inconsistent"] != true { + t.Error("expected hash_size_inconsistent true") + } + sizes, ok := node["hash_sizes_seen"].([]int) + if !ok { + t.Fatal("expected hash_sizes_seen to be []int") + } + if len(sizes) != 2 || sizes[0] != 1 || sizes[1] != 2 { + t.Errorf("expected [1,2], got %v", sizes) + } + }) -t.Run("single size omits sizes_seen", func(t *testing.T) { -node := map[string]interface{}{ -"public_key": "abc123", -"hash_size": nil, -"hash_size_inconsistent": false, -} -info := &hashSizeNodeInfo{ -HashSize: 3, -AllSizes: map[int]bool{3: true}, -Seq: []int{3, 3, 3}, -} -EnrichNodeWithHashSize(node, info) -if node["hash_size"] != 3 { -t.Errorf("expected hash_size 3, got %v", node["hash_size"]) -} -if node["hash_size_inconsistent"] != false { -t.Error("expected hash_size_inconsistent false") -} -if _, exists := node["hash_sizes_seen"]; exists { -t.Error("hash_sizes_seen should not be set for single size") -} -}) + t.Run("single size omits sizes_seen", func(t *testing.T) { + node := map[string]interface{}{ + "public_key": "abc123", + "hash_size": nil, + "hash_size_inconsistent": false, + } + info := &hashSizeNodeInfo{ + HashSize: 3, + AllSizes: map[int]bool{3: true}, + Seq: []int{3, 3, 3}, + } + EnrichNodeWithHashSize(node, info) + if node["hash_size"] != 3 { + t.Errorf("expected hash_size 3, got %v", node["hash_size"]) + } + if node["hash_size_inconsistent"] != false { + t.Error("expected hash_size_inconsistent false") + } + if _, exists := node["hash_sizes_seen"]; exists { + t.Error("hash_sizes_seen should not be set for single size") + } + }) } func TestGetNodeHashSizeInfoFlipFlop(t *testing.T) { -db := setupTestDB(t) -seedTestData(t, db) -store := NewPacketStore(db, nil) -if err := store.Load(); err != nil { - t.Fatalf("store.Load failed: %v", err) -} + db := setupTestDB(t) + seedTestData(t, db) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } -pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" -db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk) + pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk) -decoded := `{"name":"TestNode","pubKey":"` + pk + `"}` -raw1 := "04" + "00" + "aabb" -raw2 := "04" + "40" + "aabb" + decoded := `{"name":"TestNode","pubKey":"` + pk + `"}` + raw1 := "04" + "00" + "aabb" + raw2 := "04" + "40" + "aabb" -payloadType := 4 -for i := 0; i < 3; i++ { -rawHex := raw1 -if i%2 == 1 { -rawHex = raw2 -} -tx := &StoreTx{ -ID: 9000 + i, -RawHex: rawHex, -Hash: "testhash" + strconv.Itoa(i), -FirstSeen: "2024-01-01T00:00:00Z", -PayloadType: &payloadType, -DecodedJSON: decoded, -} -store.packets = append(store.packets, tx) -store.byPayloadType[4] = append(store.byPayloadType[4], tx) -} + payloadType := 4 + for i := 0; i < 3; i++ { + rawHex := raw1 + if i%2 == 1 { + rawHex = raw2 + } + tx := &StoreTx{ + ID: 9000 + i, + RawHex: rawHex, + Hash: "testhash" + strconv.Itoa(i), + FirstSeen: "2024-01-01T00:00:00Z", + PayloadType: &payloadType, + DecodedJSON: decoded, + } + store.packets = append(store.packets, tx) + store.byPayloadType[4] = append(store.byPayloadType[4], tx) + } -info := store.GetNodeHashSizeInfo() -ni := info[pk] -if ni == nil { -t.Fatal("expected hash info for test node") -} -if len(ni.AllSizes) != 2 { -t.Errorf("expected 2 unique sizes, got %d", len(ni.AllSizes)) -} -if !ni.Inconsistent { -t.Error("expected inconsistent flag to be true for flip-flop pattern") -} + info := store.GetNodeHashSizeInfo() + ni := info[pk] + if ni == nil { + t.Fatal("expected hash info for test node") + } + if len(ni.AllSizes) != 2 { + t.Errorf("expected 2 unique sizes, got %d", len(ni.AllSizes)) + } + if !ni.Inconsistent { + t.Error("expected inconsistent flag to be true for flip-flop pattern") + } } func TestGetNodeHashSizeInfoDominant(t *testing.T) { -// A node that sends mostly 2-byte adverts but occasionally 1-byte (pathByte=0x00 -// on direct sends) should report HashSize=2, not 1. -db := setupTestDB(t) -seedTestData(t, db) -store := NewPacketStore(db, nil) -if err := store.Load(); err != nil { - t.Fatalf("store.Load failed: %v", err) -} - -pk := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'Repeater2B', 'repeater')", pk) - -decoded := `{"name":"Repeater2B","pubKey":"` + pk + `"}` -raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, no hops) -raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2 + // A node that sends mostly 2-byte adverts but occasionally 1-byte (pathByte=0x00 + // on direct sends) should report HashSize=2, not 1. + db := setupTestDB(t) + seedTestData(t, db) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } -payloadType := 4 -// 1 packet with hashSize=1, 4 packets with hashSize=2 -raws := []string{raw1byte, raw2byte, raw2byte, raw2byte, raw2byte} -for i, raw := range raws { - tx := &StoreTx{ - ID: 8000 + i, - RawHex: raw, - Hash: "dominant" + strconv.Itoa(i), - FirstSeen: "2024-01-01T00:00:00Z", - PayloadType: &payloadType, - DecodedJSON: decoded, + pk := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'Repeater2B', 'repeater')", pk) + + decoded := `{"name":"Repeater2B","pubKey":"` + pk + `"}` + raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, no hops) + raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2 + + payloadType := 4 + // 1 packet with hashSize=1, 4 packets with hashSize=2 + raws := []string{raw1byte, raw2byte, raw2byte, raw2byte, raw2byte} + for i, raw := range raws { + tx := &StoreTx{ + ID: 8000 + i, + RawHex: raw, + Hash: "dominant" + strconv.Itoa(i), + FirstSeen: "2024-01-01T00:00:00Z", + PayloadType: &payloadType, + DecodedJSON: decoded, + } + store.packets = append(store.packets, tx) + store.byPayloadType[4] = append(store.byPayloadType[4], tx) } - store.packets = append(store.packets, tx) - store.byPayloadType[4] = append(store.byPayloadType[4], tx) -} -info := store.GetNodeHashSizeInfo() -ni := info[pk] -if ni == nil { - t.Fatal("expected hash info for test node") -} -if ni.HashSize != 2 { - t.Errorf("HashSize=%d, want 2 (dominant size should win over occasional 1-byte)", ni.HashSize) -} + info := store.GetNodeHashSizeInfo() + ni := info[pk] + if ni == nil { + t.Fatal("expected hash info for test node") + } + if ni.HashSize != 2 { + t.Errorf("HashSize=%d, want 2 (dominant size should win over occasional 1-byte)", ni.HashSize) + } } func TestAnalyticsHashSizesNoNullArrays(t *testing.T) { -_, router := setupTestServer(t) -req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil) -w := httptest.NewRecorder() -router.ServeHTTP(w, req) + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) -if w.Code != 200 { -t.Fatalf("expected 200, got %d", w.Code) -} + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } -var body map[string]interface{} -json.Unmarshal(w.Body.Bytes(), &body) + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) -arrayFields := []string{"hourly", "topHops", "multiByteNodes"} -for _, field := range arrayFields { -if body[field] == nil { -t.Errorf("field %q is null, expected []", field) -} + arrayFields := []string{"hourly", "topHops", "multiByteNodes"} + for _, field := range arrayFields { + if body[field] == nil { + t.Errorf("field %q is null, expected []", field) + } } } func TestObserverAnalyticsNoStore(t *testing.T) { diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..2e7c44d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/corescope + +go 1.22 diff --git a/internal/decoder/decoder.go b/internal/decoder/decoder.go new file mode 100644 index 00000000..8824e9b6 --- /dev/null +++ b/internal/decoder/decoder.go @@ -0,0 +1,740 @@ +package decoder + +import ( + "crypto/aes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "strings" + "time" + "unicode/utf8" +) + +// Route type constants (header bits 1-0) +const ( + RouteTransportFlood = 0 + RouteFlood = 1 + RouteDirect = 2 + RouteTransportDirect = 3 +) + +// Payload type constants (header bits 5-2) +const ( + PayloadREQ = 0x00 + PayloadRESPONSE = 0x01 + PayloadTXT_MSG = 0x02 + PayloadACK = 0x03 + PayloadADVERT = 0x04 + PayloadGRP_TXT = 0x05 + PayloadGRP_DATA = 0x06 + PayloadANON_REQ = 0x07 + PayloadPATH = 0x08 + PayloadTRACE = 0x09 + PayloadMULTIPART = 0x0A + PayloadCONTROL = 0x0B + PayloadRAW_CUSTOM = 0x0F +) + +var routeTypeNames = map[int]string{ + 0: "TRANSPORT_FLOOD", + 1: "FLOOD", + 2: "DIRECT", + 3: "TRANSPORT_DIRECT", +} + +var payloadTypeNames = map[int]string{ + 0x00: "REQ", + 0x01: "RESPONSE", + 0x02: "TXT_MSG", + 0x03: "ACK", + 0x04: "ADVERT", + 0x05: "GRP_TXT", + 0x06: "GRP_DATA", + 0x07: "ANON_REQ", + 0x08: "PATH", + 0x09: "TRACE", + 0x0A: "MULTIPART", + 0x0B: "CONTROL", + 0x0F: "RAW_CUSTOM", +} + +// Header is the decoded packet header. +type Header struct { + RouteType int `json:"routeType"` + RouteTypeName string `json:"routeTypeName"` + PayloadType int `json:"payloadType"` + PayloadTypeName string `json:"payloadTypeName"` + PayloadVersion int `json:"payloadVersion"` +} + +// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes. +type TransportCodes struct { + Code1 string `json:"code1"` + Code2 string `json:"code2"` +} + +// Path holds decoded path/hop information. +type Path struct { + HashSize int `json:"hashSize"` + HashCount int `json:"hashCount"` + Hops []string `json:"hops"` +} + +// AdvertFlags holds decoded advert flag bits. +type AdvertFlags struct { + Raw int `json:"raw"` + Type int `json:"type"` + Chat bool `json:"chat"` + Repeater bool `json:"repeater"` + Room bool `json:"room"` + Sensor bool `json:"sensor"` + HasLocation bool `json:"hasLocation"` + HasFeat1 bool `json:"hasFeat1"` + HasFeat2 bool `json:"hasFeat2"` + HasName bool `json:"hasName"` +} + +// Payload is a generic decoded payload. Fields are populated depending on type. +type Payload struct { + Type string `json:"type"` + DestHash string `json:"destHash,omitempty"` + SrcHash string `json:"srcHash,omitempty"` + MAC string `json:"mac,omitempty"` + EncryptedData string `json:"encryptedData,omitempty"` + ExtraHash string `json:"extraHash,omitempty"` + PubKey string `json:"pubKey,omitempty"` + Timestamp uint32 `json:"timestamp,omitempty"` + TimestampISO string `json:"timestampISO,omitempty"` + Signature string `json:"signature,omitempty"` + Flags *AdvertFlags `json:"flags,omitempty"` + Lat *float64 `json:"lat,omitempty"` + Lon *float64 `json:"lon,omitempty"` + Name string `json:"name,omitempty"` + Feat1 *int `json:"feat1,omitempty"` + Feat2 *int `json:"feat2,omitempty"` + BatteryMv *int `json:"battery_mv,omitempty"` + TemperatureC *float64 `json:"temperature_c,omitempty"` + ChannelHash int `json:"channelHash,omitempty"` + ChannelHashHex string `json:"channelHashHex,omitempty"` + DecryptionStatus string `json:"decryptionStatus,omitempty"` + Channel string `json:"channel,omitempty"` + Text string `json:"text,omitempty"` + Sender string `json:"sender,omitempty"` + SenderTimestamp uint32 `json:"sender_timestamp,omitempty"` + EphemeralPubKey string `json:"ephemeralPubKey,omitempty"` + PathData string `json:"pathData,omitempty"` + Tag uint32 `json:"tag,omitempty"` + AuthCode uint32 `json:"authCode,omitempty"` + TraceFlags *int `json:"traceFlags,omitempty"` + RawHex string `json:"raw,omitempty"` + Error string `json:"error,omitempty"` +} + +// DecodedPacket is the full decoded result. +type DecodedPacket struct { + Header Header `json:"header"` + TransportCodes *TransportCodes `json:"transportCodes"` + Path Path `json:"path"` + Payload Payload `json:"payload"` + Raw string `json:"raw"` +} + +func decodeHeader(b byte) Header { + rt := int(b & 0x03) + pt := int((b >> 2) & 0x0F) + pv := int((b >> 6) & 0x03) + + rtName := routeTypeNames[rt] + if rtName == "" { + rtName = "UNKNOWN" + } + ptName := payloadTypeNames[pt] + if ptName == "" { + ptName = "UNKNOWN" + } + + return Header{ + RouteType: rt, + RouteTypeName: rtName, + PayloadType: pt, + PayloadTypeName: ptName, + PayloadVersion: pv, + } +} + +func decodePath(pathByte byte, buf []byte, offset int) (Path, int) { + hashSize := int(pathByte>>6) + 1 + hashCount := int(pathByte & 0x3F) + totalBytes := hashSize * hashCount + hops := make([]string, 0, hashCount) + + for i := 0; i < hashCount; i++ { + start := offset + i*hashSize + end := start + hashSize + if end > len(buf) { + break + } + hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end]))) + } + + return Path{ + HashSize: hashSize, + HashCount: hashCount, + Hops: hops, + }, totalBytes +} + +func isTransportRoute(routeType int) bool { + return routeType == RouteTransportFlood || routeType == RouteTransportDirect +} + +func decodeEncryptedPayload(typeName string, buf []byte) Payload { + if len(buf) < 4 { + return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)} + } + return Payload{ + Type: typeName, + DestHash: hex.EncodeToString(buf[0:1]), + SrcHash: hex.EncodeToString(buf[1:2]), + MAC: hex.EncodeToString(buf[2:4]), + EncryptedData: hex.EncodeToString(buf[4:]), + } +} + +func decodeAck(buf []byte) Payload { + if len(buf) < 4 { + return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)} + } + checksum := binary.LittleEndian.Uint32(buf[0:4]) + return Payload{ + Type: "ACK", + ExtraHash: fmt.Sprintf("%08x", checksum), + } +} + +func decodeAdvert(buf []byte) Payload { + if len(buf) < 100 { + return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)} + } + + pubKey := hex.EncodeToString(buf[0:32]) + timestamp := binary.LittleEndian.Uint32(buf[32:36]) + signature := hex.EncodeToString(buf[36:100]) + appdata := buf[100:] + + p := Payload{ + Type: "ADVERT", + PubKey: pubKey, + Timestamp: timestamp, + TimestampISO: fmt.Sprintf("%s", EpochToISO(timestamp)), + Signature: signature, + } + + if len(appdata) > 0 { + flags := appdata[0] + advType := int(flags & 0x0F) + hasFeat1 := flags&0x20 != 0 + hasFeat2 := flags&0x40 != 0 + p.Flags = &AdvertFlags{ + Raw: int(flags), + Type: advType, + Chat: advType == 1, + Repeater: advType == 2, + Room: advType == 3, + Sensor: advType == 4, + HasLocation: flags&0x10 != 0, + HasFeat1: hasFeat1, + HasFeat2: hasFeat2, + HasName: flags&0x80 != 0, + } + + off := 1 + if p.Flags.HasLocation && len(appdata) >= off+8 { + latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4])) + lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8])) + lat := float64(latRaw) / 1e6 + lon := float64(lonRaw) / 1e6 + p.Lat = &lat + p.Lon = &lon + off += 8 + } + if hasFeat1 && len(appdata) >= off+2 { + feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2])) + p.Feat1 = &feat1 + off += 2 + } + if hasFeat2 && len(appdata) >= off+2 { + feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2])) + p.Feat2 = &feat2 + off += 2 + } + if p.Flags.HasName { + // Find null terminator to separate name from trailing telemetry bytes + nameEnd := len(appdata) + for i := off; i < len(appdata); i++ { + if appdata[i] == 0x00 { + nameEnd = i + break + } + } + name := string(appdata[off:nameEnd]) + name = sanitizeName(name) + p.Name = name + off = nameEnd + // Skip null terminator(s) + for off < len(appdata) && appdata[off] == 0x00 { + off++ + } + } + + // Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100) + // Only sensor nodes (advType=4) carry telemetry bytes. + if p.Flags.Sensor && off+4 <= len(appdata) { + batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2])) + tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4])) + tempC := float64(tempRaw) / 100.0 + if batteryMv > 0 && batteryMv <= 10000 { + p.BatteryMv = &batteryMv + } + // Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000) + if tempRaw >= -5000 && tempRaw <= 10000 { + p.TemperatureC = &tempC + } + } + } + + return p +} + +// channelDecryptResult holds the decrypted channel message fields. +type channelDecryptResult struct { + Timestamp uint32 + Flags byte + Sender string + Message string +} + +// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t). +func countNonPrintable(s string) int { + count := 0 + for _, r := range s { + if r < 0x20 && r != '\n' && r != '\t' { + count++ + } else if r == utf8.RuneError { + count++ + } + } + return count +} + +// decryptChannelMessage implements MeshCore channel decryption: +// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption. +func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) { + channelKey, err := hex.DecodeString(channelKeyHex) + if err != nil || len(channelKey) != 16 { + return nil, fmt.Errorf("invalid channel key") + } + + macBytes, err := hex.DecodeString(macHex) + if err != nil || len(macBytes) != 2 { + return nil, fmt.Errorf("invalid MAC") + } + + ciphertext, err := hex.DecodeString(ciphertextHex) + if err != nil || len(ciphertext) == 0 { + return nil, fmt.Errorf("invalid ciphertext") + } + + // 32-byte channel secret: 16-byte key + 16 zero bytes + channelSecret := make([]byte, 32) + copy(channelSecret, channelKey) + + // Verify HMAC-SHA256 (first 2 bytes must match provided MAC) + h := hmac.New(sha256.New, channelSecret) + h.Write(ciphertext) + calculatedMac := h.Sum(nil) + if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] { + return nil, fmt.Errorf("MAC verification failed") + } + + // AES-128-ECB decrypt (block-by-block, no padding) + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("ciphertext not aligned to AES block size") + } + block, err := aes.NewCipher(channelKey) + if err != nil { + return nil, fmt.Errorf("AES cipher: %w", err) + } + plaintext := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + + // Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated) + if len(plaintext) < 5 { + return nil, fmt.Errorf("decrypted content too short") + } + timestamp := binary.LittleEndian.Uint32(plaintext[0:4]) + flags := plaintext[4] + messageText := string(plaintext[5:]) + if idx := strings.IndexByte(messageText, 0); idx >= 0 { + messageText = messageText[:idx] + } + + // Validate decrypted text is printable UTF-8 (not binary garbage) + if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 { + return nil, fmt.Errorf("decrypted text contains non-printable characters") + } + + result := &channelDecryptResult{Timestamp: timestamp, Flags: flags} + + // Parse "sender: message" format + colonIdx := strings.Index(messageText, ": ") + if colonIdx > 0 && colonIdx < 50 { + potentialSender := messageText[:colonIdx] + if !strings.ContainsAny(potentialSender, ":[]") { + result.Sender = potentialSender + result.Message = messageText[colonIdx+2:] + } else { + result.Message = messageText + } + } else { + result.Message = messageText + } + + return result, nil +} + +func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload { + if len(buf) < 3 { + return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)} + } + + channelHash := int(buf[0]) + channelHashHex := fmt.Sprintf("%02X", buf[0]) + mac := hex.EncodeToString(buf[1:3]) + encryptedData := hex.EncodeToString(buf[3:]) + + hasKeys := len(channelKeys) > 0 + // Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars) + if hasKeys && len(encryptedData) >= 10 { + for name, key := range channelKeys { + result, err := decryptChannelMessage(encryptedData, mac, key) + if err != nil { + continue + } + text := result.Message + if result.Sender != "" && result.Message != "" { + text = result.Sender + ": " + result.Message + } + return Payload{ + Type: "CHAN", + Channel: name, + ChannelHash: channelHash, + ChannelHashHex: channelHashHex, + DecryptionStatus: "decrypted", + Sender: result.Sender, + Text: text, + SenderTimestamp: result.Timestamp, + } + } + return Payload{ + Type: "GRP_TXT", + ChannelHash: channelHash, + ChannelHashHex: channelHashHex, + DecryptionStatus: "decryption_failed", + MAC: mac, + EncryptedData: encryptedData, + } + } + + return Payload{ + Type: "GRP_TXT", + ChannelHash: channelHash, + ChannelHashHex: channelHashHex, + DecryptionStatus: "no_key", + MAC: mac, + EncryptedData: encryptedData, + } +} + +func decodeAnonReq(buf []byte) Payload { + if len(buf) < 35 { + return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)} + } + return Payload{ + Type: "ANON_REQ", + DestHash: hex.EncodeToString(buf[0:1]), + EphemeralPubKey: hex.EncodeToString(buf[1:33]), + MAC: hex.EncodeToString(buf[33:35]), + EncryptedData: hex.EncodeToString(buf[35:]), + } +} + +func decodePathPayload(buf []byte) Payload { + if len(buf) < 4 { + return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)} + } + return Payload{ + Type: "PATH", + DestHash: hex.EncodeToString(buf[0:1]), + SrcHash: hex.EncodeToString(buf[1:2]), + MAC: hex.EncodeToString(buf[2:4]), + PathData: hex.EncodeToString(buf[4:]), + } +} + +func decodeTrace(buf []byte) Payload { + if len(buf) < 9 { + return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)} + } + tag := binary.LittleEndian.Uint32(buf[0:4]) + authCode := binary.LittleEndian.Uint32(buf[4:8]) + flags := int(buf[8]) + p := Payload{ + Type: "TRACE", + Tag: tag, + AuthCode: authCode, + TraceFlags: &flags, + } + if len(buf) > 9 { + p.PathData = hex.EncodeToString(buf[9:]) + } + return p +} + +func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload { + switch payloadType { + case PayloadREQ: + return decodeEncryptedPayload("REQ", buf) + case PayloadRESPONSE: + return decodeEncryptedPayload("RESPONSE", buf) + case PayloadTXT_MSG: + return decodeEncryptedPayload("TXT_MSG", buf) + case PayloadACK: + return decodeAck(buf) + case PayloadADVERT: + return decodeAdvert(buf) + case PayloadGRP_TXT: + return decodeGrpTxt(buf, channelKeys) + case PayloadANON_REQ: + return decodeAnonReq(buf) + case PayloadPATH: + return decodePathPayload(buf) + case PayloadTRACE: + return decodeTrace(buf) + default: + return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)} + } +} + +// DecodePacket decodes a hex-encoded MeshCore packet. +func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) { + hexString = strings.ReplaceAll(hexString, " ", "") + hexString = strings.ReplaceAll(hexString, "\n", "") + hexString = strings.ReplaceAll(hexString, "\r", "") + + buf, err := hex.DecodeString(hexString) + if err != nil { + return nil, fmt.Errorf("invalid hex: %w", err) + } + if len(buf) < 2 { + return nil, fmt.Errorf("packet too short (need at least header + pathLength)") + } + + header := decodeHeader(buf[0]) + offset := 1 + + var tc *TransportCodes + if isTransportRoute(header.RouteType) { + if len(buf) < offset+4 { + return nil, fmt.Errorf("packet too short for transport codes") + } + tc = &TransportCodes{ + Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])), + Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])), + } + offset += 4 + } + + if offset >= len(buf) { + return nil, fmt.Errorf("packet too short (no path byte)") + } + pathByte := buf[offset] + offset++ + + path, bytesConsumed := decodePath(pathByte, buf, offset) + offset += bytesConsumed + + payloadBuf := buf[offset:] + payload := decodePayload(header.PayloadType, payloadBuf, channelKeys) + + // TRACE packets store hop IDs in the payload (buf[9:]) rather than the header + // path field. The header path byte still encodes hashSize in bits 6-7, which + // we use to split the payload path data into individual hop prefixes. + if header.PayloadType == PayloadTRACE && payload.PathData != "" { + pathBytes, err := hex.DecodeString(payload.PathData) + if err == nil && path.HashSize > 0 { + hops := make([]string, 0, len(pathBytes)/path.HashSize) + for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize { + hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize]))) + } + path.Hops = hops + path.HashCount = len(hops) + } + } + + return &DecodedPacket{ + Header: header, + TransportCodes: tc, + Path: path, + Payload: payload, + Raw: strings.ToUpper(hexString), + }, nil +} + +// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars). +// It hashes the header byte + payload (skipping path bytes) to produce a +// path-independent identifier for the same transmission. +func ComputeContentHash(rawHex string) string { + buf, err := hex.DecodeString(rawHex) + if err != nil || len(buf) < 2 { + if len(rawHex) >= 16 { + return rawHex[:16] + } + return rawHex + } + + headerByte := buf[0] + offset := 1 + if isTransportRoute(int(headerByte & 0x03)) { + offset += 4 + } + if offset >= len(buf) { + if len(rawHex) >= 16 { + return rawHex[:16] + } + return rawHex + } + pathByte := buf[offset] + offset++ + hashSize := int((pathByte>>6)&0x3) + 1 + hashCount := int(pathByte & 0x3F) + pathBytes := hashSize * hashCount + + payloadStart := offset + pathBytes + if payloadStart > len(buf) { + if len(rawHex) >= 16 { + return rawHex[:16] + } + return rawHex + } + + payload := buf[payloadStart:] + toHash := append([]byte{headerByte}, payload...) + + h := sha256.Sum256(toHash) + return hex.EncodeToString(h[:])[:16] +} + +// PayloadJSON serializes the payload to JSON for DB storage. +func PayloadJSON(p *Payload) string { + b, err := json.Marshal(p) + if err != nil { + return "{}" + } + return string(b) +} + +// ValidateAdvert checks decoded advert data before DB insertion. +func ValidateAdvert(p *Payload) (bool, string) { + if p == nil || p.Error != "" { + reason := "null advert" + if p != nil { + reason = p.Error + } + return false, reason + } + + pk := p.PubKey + if len(pk) < 16 { + return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk)) + } + allZero := true + for _, c := range pk { + if c != '0' { + allZero = false + break + } + } + if allZero { + return false, "pubkey is all zeros" + } + + if p.Lat != nil { + if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 { + return false, fmt.Sprintf("invalid lat: %f", *p.Lat) + } + } + if p.Lon != nil { + if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 { + return false, fmt.Sprintf("invalid lon: %f", *p.Lon) + } + } + + if p.Name != "" { + for _, c := range p.Name { + if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f { + return false, "name contains control characters" + } + } + if len(p.Name) > 64 { + return false, fmt.Sprintf("name too long (%d chars)", len(p.Name)) + } + } + + if p.Flags != nil { + role := AdvertRole(p.Flags) + validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true} + if !validRoles[role] { + return false, fmt.Sprintf("unknown role: %s", role) + } + } + + return true, "" +} + +// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL. +func sanitizeName(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, c := range s { + if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) { + b.WriteRune(c) + } + } + return b.String() +} + +func AdvertRole(f *AdvertFlags) string { + if f.Repeater { + return "repeater" + } + if f.Room { + return "room" + } + if f.Sensor { + return "sensor" + } + return "companion" +} + +func EpochToISO(epoch uint32) string { + // Go time from Unix epoch + t := time.Unix(int64(epoch), 0) + return t.UTC().Format("2006-01-02T15:04:05.000Z") +} diff --git a/cmd/ingestor/decoder_test.go b/internal/decoder/decoder_test.go similarity index 93% rename from cmd/ingestor/decoder_test.go rename to internal/decoder/decoder_test.go index 1b60bc94..b4482db0 100644 --- a/cmd/ingestor/decoder_test.go +++ b/internal/decoder/decoder_test.go @@ -1,1544 +1,1544 @@ -package main - -import ( - "crypto/aes" - "crypto/hmac" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "math" - "strings" - "testing" -) - -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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - 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) - if err == nil { - t.Error("expected error for 1-byte packet") - } -} - -func TestDecodePacketInvalidHex(t *testing.T) { - _, err := DecodePacket("ZZZZ", nil) - 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 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) - } -} - -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) - 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)) - 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) { - buf := []byte{0x01, 0x02, 0x03} - p := decodePayload(PayloadGRP_DATA, buf, nil) - if p.Type != "UNKNOWN" { - t.Errorf("type=%s, want UNKNOWN", p.Type) - } - if p.RawHex != "010203" { - t.Errorf("rawHex=%s, want 010203", p.RawHex) - } -} - -func TestDecodePayloadRAWCustom(t *testing.T) { - buf := []byte{0xFF, 0xFE} - p := decodePayload(PayloadRAW_CUSTOM, buf, nil) - if p.Type != "UNKNOWN" { - t.Errorf("type=%s, want UNKNOWN", p.Type) - } -} - -func TestDecodePayloadAllTypes(t *testing.T) { - // REQ - p := decodePayload(PayloadREQ, make([]byte, 10), nil) - if p.Type != "REQ" { - t.Errorf("REQ: type=%s", p.Type) - } - - // RESPONSE - p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil) - if p.Type != "RESPONSE" { - t.Errorf("RESPONSE: type=%s", p.Type) - } - - // TXT_MSG - p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil) - if p.Type != "TXT_MSG" { - t.Errorf("TXT_MSG: type=%s", p.Type) - } - - // ACK - p = decodePayload(PayloadACK, make([]byte, 10), nil) - if p.Type != "ACK" { - t.Errorf("ACK: type=%s", p.Type) - } - - // GRP_TXT - p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil) - if p.Type != "GRP_TXT" { - t.Errorf("GRP_TXT: type=%s", p.Type) - } - - // ANON_REQ - p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil) - if p.Type != "ANON_REQ" { - t.Errorf("ANON_REQ: type=%s", p.Type) - } - - // PATH - p = decodePayload(PayloadPATH, make([]byte, 10), nil) - if p.Type != "PATH" { - t.Errorf("PATH: type=%s", p.Type) - } - - // TRACE - p = decodePayload(PayloadTRACE, make([]byte, 20), nil) - 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) - } -} - -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) - 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) - 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) - 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) falls through to default → UNKNOWN - p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil) - if p.Type != "UNKNOWN" { - t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type) - } -} - -func TestDecodePayloadControl(t *testing.T) { - // CONTROL (0x0B) falls through to default → UNKNOWN - p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil) - if p.Type != "UNKNOWN" { - t.Errorf("CONTROL type=%s, want UNKNOWN", 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) - 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) - 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) - 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) - 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) - 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) - 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) - } -} +package decoder + +import ( + "crypto/aes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "math" + "strings" + "testing" +) + +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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + if err == nil { + t.Error("expected error for 1-byte packet") + } +} + +func TestDecodePacketInvalidHex(t *testing.T) { + _, err := DecodePacket("ZZZZ", nil) + 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 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) + } +} + +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) + 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)) + 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) { + buf := []byte{0x01, 0x02, 0x03} + p := decodePayload(PayloadGRP_DATA, buf, nil) + if p.Type != "UNKNOWN" { + t.Errorf("type=%s, want UNKNOWN", p.Type) + } + if p.RawHex != "010203" { + t.Errorf("rawHex=%s, want 010203", p.RawHex) + } +} + +func TestDecodePayloadRAWCustom(t *testing.T) { + buf := []byte{0xFF, 0xFE} + p := decodePayload(PayloadRAW_CUSTOM, buf, nil) + if p.Type != "UNKNOWN" { + t.Errorf("type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePayloadAllTypes(t *testing.T) { + // REQ + p := decodePayload(PayloadREQ, make([]byte, 10), nil) + if p.Type != "REQ" { + t.Errorf("REQ: type=%s", p.Type) + } + + // RESPONSE + p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil) + if p.Type != "RESPONSE" { + t.Errorf("RESPONSE: type=%s", p.Type) + } + + // TXT_MSG + p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil) + if p.Type != "TXT_MSG" { + t.Errorf("TXT_MSG: type=%s", p.Type) + } + + // ACK + p = decodePayload(PayloadACK, make([]byte, 10), nil) + if p.Type != "ACK" { + t.Errorf("ACK: type=%s", p.Type) + } + + // GRP_TXT + p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil) + if p.Type != "GRP_TXT" { + t.Errorf("GRP_TXT: type=%s", p.Type) + } + + // ANON_REQ + p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil) + if p.Type != "ANON_REQ" { + t.Errorf("ANON_REQ: type=%s", p.Type) + } + + // PATH + p = decodePayload(PayloadPATH, make([]byte, 10), nil) + if p.Type != "PATH" { + t.Errorf("PATH: type=%s", p.Type) + } + + // TRACE + p = decodePayload(PayloadTRACE, make([]byte, 20), nil) + 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) + } +} + +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) + 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) + 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) + 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) falls through to default ΓåÆ UNKNOWN + p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil) + if p.Type != "UNKNOWN" { + t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePayloadControl(t *testing.T) { + // CONTROL (0x0B) falls through to default ΓåÆ UNKNOWN + p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil) + if p.Type != "UNKNOWN" { + t.Errorf("CONTROL type=%s, want UNKNOWN", 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) + 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) + 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) + 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) + 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) + 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) + 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) + } +} diff --git a/public/channels.js b/public/channels.js index 6000e286..d2e499c3 100644 --- a/public/channels.js +++ b/public/channels.js @@ -9,9 +9,7 @@ let autoScroll = true; let nodeCache = {}; let selectedNode = null; - let observerIataById = {}; - let observerIataByName = {}; - let messageRequestId = 0; + let observerIataMap = {}; var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes function getSelectedRegionsSnapshot() { @@ -19,28 +17,11 @@ return rp ? rp.split(',').filter(Boolean) : null; } - function normalizeObserverNameKey(name) { - if (!name) return ''; - return String(name).trim().toLowerCase(); - } - - function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegionsById, observerRegionsByName) { + function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegions) { if (!selectedRegions || !selectedRegions.length) return true; - if (observerRegionsById && observerRegionsById.byId) { - observerRegionsByName = observerRegionsById.byName || {}; - observerRegionsById = observerRegionsById.byId || {}; - } - observerRegionsById = observerRegionsById || {}; - observerRegionsByName = observerRegionsByName || {}; - var observerId = msg?.data?.packet?.observer_id || msg?.data?.observer_id || null; - var observerRegion = observerId ? observerRegionsById[observerId] : null; - if (!observerRegion) { - var observerName = msg?.data?.packet?.observer_name || msg?.data?.observer_name || msg?.data?.observer || null; - var observerNameKey = normalizeObserverNameKey(observerName); - if (observerName) observerRegion = observerRegionsByName[observerName]; - if (!observerRegion && observerNameKey) observerRegion = observerRegionsByName[observerNameKey]; - } + if (!observerId) return false; + var observerRegion = observerRegions[observerId]; if (!observerRegion) return false; return selectedRegions.indexOf(observerRegion) !== -1; } @@ -49,53 +30,17 @@ try { var data = await api('/observers', { ttl: CLIENT_TTL.observers }); var list = data && data.observers ? data.observers : []; - var byId = {}; - var byName = {}; + var map = {}; for (var i = 0; i < list.length; i++) { var o = list[i]; var id = o.id || o.observer_id; - var name = o.name || o.observer_name; - if (!o.iata) continue; - if (id) byId[id] = o.iata; - if (name) { - byName[name] = o.iata; - var key = normalizeObserverNameKey(name); - if (key) byName[key] = o.iata; - } + if (!id || !o.iata) continue; + map[id] = o.iata; } - observerIataById = byId; - observerIataByName = byName; + observerIataMap = map; } catch {} } - function beginMessageRequest(hash, regionParam) { - return { id: ++messageRequestId, hash: hash, regionParam: regionParam || '' }; - } - - function isStaleMessageRequest(req) { - if (!req) return true; - var currentRegion = RegionFilter.getRegionParam() || ''; - if (req.id !== messageRequestId) return true; - if (selectedHash !== req.hash) return true; - if (currentRegion !== req.regionParam) return true; - return false; - } - - function reconcileSelectionAfterChannelRefresh() { - if (!selectedHash || channels.some(ch => ch.hash === selectedHash)) return false; - selectedHash = null; - messages = []; - history.replaceState(null, '', '#/channels'); - renderChannelList(); - const header = document.getElementById('chHeader'); - if (header) header.querySelector('.ch-header-text').textContent = 'Select a channel'; - const msgEl = document.getElementById('chMessages'); - if (msgEl) msgEl.innerHTML = '
Choose a channel from the sidebar to view messages
'; - document.querySelector('.ch-layout')?.classList.remove('ch-show-main'); - document.getElementById('chScrollBtn')?.classList.add('hidden'); - return true; - } - async function lookupNode(name) { var cached = nodeCache[name]; if (cached !== undefined) { @@ -473,7 +418,8 @@ } }); - function processWSBatch(msgs, selectedRegions) { + wsHandler = debouncedOnWS(function (msgs) { + var selectedRegions = getSelectedRegionsSnapshot(); var dominated = msgs.filter(function (m) { return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT'); }); @@ -485,7 +431,7 @@ for (var i = 0; i < dominated.length; i++) { var m = dominated[i]; - if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataById, observerIataByName)) continue; + if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataMap)) continue; var payload = m.data?.decoded?.payload; if (!payload) continue; @@ -586,18 +532,7 @@ if (liveEl) liveEl.textContent = 'New message received'; } } - } - - function handleWSBatch(msgs) { - var selectedRegions = getSelectedRegionsSnapshot(); - processWSBatch(msgs, selectedRegions); - } - - wsHandler = debouncedOnWS(function (msgs) { - handleWSBatch(msgs); }); - window._channelsHandleWSBatchForTest = handleWSBatch; - window._channelsProcessWSBatchForTest = processWSBatch; // Tick relative timestamps every 1s — iterates channels array, updates DOM text only timeAgoTimer = setInterval(function () { @@ -639,7 +574,6 @@ return ch; }).sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0)); renderChannelList(); - reconcileSelectionAfterChannelRefresh(); } catch (e) { if (!silent) { const el = document.getElementById('chList'); @@ -682,8 +616,6 @@ } async function selectChannel(hash) { - const rp = RegionFilter.getRegionParam() || ''; - const request = beginMessageRequest(hash, rp); selectedHash = hash; history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`); renderChannelList(); @@ -699,9 +631,9 @@ msgEl.innerHTML = '
Loading messages…
'; try { + const rp = RegionFilter.getRegionParam(); const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : ''; const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages }); - if (isStaleMessageRequest(request)) return; messages = data.messages || []; if (messages.length === 0 && rp) { msgEl.innerHTML = '
Channel not available in selected region
'; @@ -710,7 +642,6 @@ scrollToBottom(); } } catch (e) { - if (isStaleMessageRequest(request)) return; msgEl.innerHTML = `
Failed to load messages: ${e.message}
`; } } @@ -722,12 +653,9 @@ if (!msgEl) return; const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60; try { - const requestHash = selectedHash; - const rp = RegionFilter.getRegionParam() || ''; - const request = beginMessageRequest(requestHash, rp); + const rp = RegionFilter.getRegionParam(); const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : ''; - const data = await api(`/channels/${encodeURIComponent(requestHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache }); - if (isStaleMessageRequest(request)) return; + const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache }); const newMsgs = data.messages || []; if (opts.regionSwitch && rp && newMsgs.length === 0) { messages = []; @@ -790,25 +718,6 @@ if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); } } - window._channelsSetStateForTest = function (state) { - if (!state) return; - if (Array.isArray(state.channels)) channels = state.channels; - if (Array.isArray(state.messages)) messages = state.messages; - if (Object.prototype.hasOwnProperty.call(state, 'selectedHash')) selectedHash = state.selectedHash; - }; - window._channelsSetObserverRegionsForTest = function (byId, byName) { - observerIataById = byId || {}; - observerIataByName = byName || {}; - }; - window._channelsSelectChannelForTest = selectChannel; - window._channelsRefreshMessagesForTest = refreshMessages; - window._channelsLoadChannelsForTest = loadChannels; - window._channelsBeginMessageRequestForTest = beginMessageRequest; - window._channelsIsStaleMessageRequestForTest = isStaleMessageRequest; - window._channelsReconcileSelectionForTest = reconcileSelectionAfterChannelRefresh; - window._channelsGetStateForTest = function () { - return { channels: channels, messages: messages, selectedHash: selectedHash }; - }; window._channelsShouldProcessWSMessageForRegion = shouldProcessWSMessageForRegion; registerPage('channels', { init, destroy }); })(); diff --git a/public/index.html b/public/index.html index 39d1836c..6eb4d5a7 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,31 +81,31 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index cd071748..1e431b4c 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -1968,327 +1968,11 @@ console.log('\n=== channels.js: shouldProcessWSMessageForRegion ==='); assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false); }); - test('falls back to observer_name mapping when observer_id is missing', () => { - const msg = { data: { packet: { observer_name: 'Observer Alpha' } } }; - assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'LAX' }, { 'Observer Alpha': 'SJC' }), true); - }); - test('drops message when observer region lookup missing', () => { const msg = { data: { packet: { observer_id: 'obs9' } } }; assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false); }); } - -console.log('\n=== channels.js: WS batch + region snapshot integration ==='); -{ - function makeChannelsWsSandbox(regionParam) { - const ctx = makeSandbox(); - const dom = {}; - function makeEl(id) { - if (dom[id]) return dom[id]; - dom[id] = { - id, - innerHTML: '', - textContent: '', - value: '', - scrollTop: 0, - scrollHeight: 100, - clientHeight: 80, - style: {}, - dataset: {}, - classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, - addEventListener() {}, - removeEventListener() {}, - querySelector() { return null; }, - querySelectorAll() { return []; }, - getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; }, - setAttribute() {}, - removeAttribute() {}, - focus() {}, - }; - return dom[id]; - } - - const headerText = { textContent: '' }; - makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null); - makeEl('chMessages'); - makeEl('chList'); - makeEl('chScrollBtn'); - makeEl('chAriaLive'); - makeEl('chBackBtn'); - makeEl('chRegionFilter'); - - const appEl = { - innerHTML: '', - querySelector(sel) { - if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel); - if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; - return makeEl(sel); - }, - addEventListener() {}, - }; - - ctx.document.getElementById = makeEl; - ctx.document.querySelector = (sel) => { - if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; - return null; - }; - ctx.document.querySelectorAll = () => []; - ctx.document.addEventListener = () => {}; - ctx.document.removeEventListener = () => {}; - ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} }; - ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } }; - ctx.history = { replaceState() {} }; - ctx.matchMedia = () => ({ matches: false }); - ctx.window.matchMedia = ctx.matchMedia; - ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; }; - ctx.RegionFilter = { - init() {}, - onChange() { return () => {}; }, - offChange() {}, - getRegionParam() { return regionParam || ''; }, - }; - ctx.debouncedOnWS = (fn) => fn; - ctx.onWS = () => {}; - ctx.offWS = () => {}; - ctx.api = (path) => { - if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); - if (path.indexOf('/channels') === 0) return Promise.resolve({ channels: [] }); - return Promise.resolve({ messages: [] }); - }; - ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 }; - ctx.ROLE_EMOJI = {}; - ctx.ROLE_LABELS = {}; - ctx.timeAgo = () => '1m ago'; - ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; }; - ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); - ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); - - loadInCtx(ctx, 'public/channels.js'); - ctx._pageHandlers.init(appEl); - return { ctx, dom }; - } - - test('WS batch respects region snapshot and observer_name fallback', () => { - const env = makeChannelsWsSandbox('SJC'); - env.ctx.window._channelsSetObserverRegionsForTest({ obs1: 'SJC' }, { 'Observer Beta': 'SJC' }); - env.ctx.window._channelsSetStateForTest({ - selectedHash: 'general', - channels: [{ hash: 'general', name: 'general', messageCount: 0, lastActivityMs: 0 }], - messages: [], - }); - - env.ctx.window._channelsHandleWSBatchForTest([ - { - type: 'packet', - data: { - hash: 'hash1', - decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text: 'Alice: hello world' } }, - packet: { observer_name: 'Observer Beta' }, - }, - }, - { - type: 'packet', - data: { - hash: 'hash2', - decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text: 'Bob: dropped' } }, - packet: { observer_name: 'Observer Zeta' }, - }, - }, - ]); - - const state = env.ctx.window._channelsGetStateForTest(); - assert.strictEqual(state.messages.length, 1, 'only matching-region message should be appended'); - assert.strictEqual(state.messages[0].sender, 'Alice'); - assert.strictEqual(state.channels[0].messageCount, 1, 'channel count increments only for accepted message'); - }); - - test('stale selectChannel response is discarded after region change', async () => { - const ctx = makeSandbox(); - const dom = {}; - function makeEl(id) { - if (dom[id]) return dom[id]; - dom[id] = { - id, - innerHTML: '', - textContent: '', - value: '', - scrollTop: 0, - scrollHeight: 100, - clientHeight: 80, - style: {}, - dataset: {}, - classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, - addEventListener() {}, - removeEventListener() {}, - querySelector() { return null; }, - querySelectorAll() { return []; }, - getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; }, - setAttribute() {}, - removeAttribute() {}, - focus() {}, - }; - return dom[id]; - } - const headerText = { textContent: '' }; - makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null); - makeEl('chMessages'); - makeEl('chList'); - makeEl('chScrollBtn'); - makeEl('chAriaLive'); - makeEl('chBackBtn'); - makeEl('chRegionFilter'); - const appEl = { - innerHTML: '', - querySelector(sel) { - if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel); - if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; - return makeEl(sel); - }, - addEventListener() {}, - }; - let region = 'SJC'; - let resolver = null; - ctx.document.getElementById = makeEl; - ctx.document.querySelector = (sel) => { - if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; - return null; - }; - ctx.document.querySelectorAll = () => []; - ctx.document.addEventListener = () => {}; - ctx.document.removeEventListener = () => {}; - ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} }; - ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } }; - ctx.history = { replaceState() {} }; - ctx.matchMedia = () => ({ matches: false }); - ctx.window.matchMedia = ctx.matchMedia; - ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; }; - ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return region; } }; - ctx.debouncedOnWS = (fn) => fn; - ctx.onWS = () => {}; - ctx.offWS = () => {}; - ctx.api = (path) => { - if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); - if (path.indexOf('/channels?') === 0 || path === '/channels') return Promise.resolve({ channels: [{ hash: 'general', name: 'general', messageCount: 2, lastActivity: null }] }); - if (path.indexOf('/channels/general/messages') === 0) { - return new Promise((resolve) => { resolver = resolve; }); - } - return Promise.resolve({ messages: [] }); - }; - ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 }; - ctx.ROLE_EMOJI = {}; - ctx.ROLE_LABELS = {}; - ctx.timeAgo = () => '1m ago'; - ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; }; - ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); - ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); - - loadInCtx(ctx, 'public/channels.js'); - ctx._pageHandlers.init(appEl); - await Promise.resolve(); - const selectPromise = ctx.window._channelsSelectChannelForTest('general'); - region = 'LAX'; - ctx.window._channelsBeginMessageRequestForTest('other', 'LAX'); - resolver({ messages: [{ sender: 'Alice', text: 'stale', timestamp: '2025-01-01T00:00:00Z' }] }); - await selectPromise; - const state = ctx.window._channelsGetStateForTest(); - assert.strictEqual(state.selectedHash, 'general', 'stale select response must not clear or overwrite selection'); - assert.strictEqual(state.messages.length, 0, 'stale response must be discarded'); - }); - - test('loadChannels clears selected hash when channel no longer exists in region', async () => { - const ctx = makeSandbox(); - const dom = {}; - function makeEl(id) { - if (dom[id]) return dom[id]; - dom[id] = { - id, - innerHTML: '', - textContent: '', - value: '', - scrollTop: 0, - scrollHeight: 100, - clientHeight: 80, - style: {}, - dataset: {}, - classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, - addEventListener() {}, - removeEventListener() {}, - querySelector() { return null; }, - querySelectorAll() { return []; }, - getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; }, - setAttribute() {}, - removeAttribute() {}, - focus() {}, - }; - return dom[id]; - } - const headerText = { textContent: '' }; - makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null); - makeEl('chMessages'); - makeEl('chList'); - makeEl('chScrollBtn'); - makeEl('chAriaLive'); - makeEl('chBackBtn'); - makeEl('chRegionFilter'); - const appEl = { - innerHTML: '', - querySelector(sel) { - if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel); - if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; - return makeEl(sel); - }, - addEventListener() {}, - }; - const historyCalls = []; - let channelCall = 0; - ctx.document.getElementById = makeEl; - ctx.document.querySelector = (sel) => { - if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; - return null; - }; - ctx.document.querySelectorAll = () => []; - ctx.document.addEventListener = () => {}; - ctx.document.removeEventListener = () => {}; - ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} }; - ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } }; - ctx.history = { replaceState(_a, _b, url) { historyCalls.push(url); } }; - ctx.matchMedia = () => ({ matches: false }); - ctx.window.matchMedia = ctx.matchMedia; - ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; }; - ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return 'SJC'; } }; - ctx.debouncedOnWS = (fn) => fn; - ctx.onWS = () => {}; - ctx.offWS = () => {}; - ctx.api = (path) => { - if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); - if (path.indexOf('/channels') === 0) { - channelCall++; - if (channelCall === 1) return Promise.resolve({ channels: [{ hash: 'general', name: 'general', messageCount: 1, lastActivity: null }] }); - return Promise.resolve({ channels: [{ hash: 'newchan', name: 'newchan', messageCount: 1, lastActivity: null }] }); - } - if (path.indexOf('/channels/general/messages') === 0) return Promise.resolve({ messages: [{ sender: 'Alice', text: 'hi', timestamp: '2025-01-01T00:00:00Z' }] }); - return Promise.resolve({ messages: [] }); - }; - ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 }; - ctx.ROLE_EMOJI = {}; - ctx.ROLE_LABELS = {}; - ctx.timeAgo = () => '1m ago'; - ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; }; - ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); - ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); - - loadInCtx(ctx, 'public/channels.js'); - ctx._pageHandlers.init(appEl); - await Promise.resolve(); - await ctx.window._channelsSelectChannelForTest('general'); - await ctx.window._channelsLoadChannelsForTest(true); - ctx.window._channelsReconcileSelectionForTest(); - const state = ctx.window._channelsGetStateForTest(); - assert.strictEqual(state.selectedHash, null, 'selection should clear when channel disappears after region update'); - assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root'); - }); -} // ===== SUMMARY ===== console.log(`\n${'═'.repeat(40)}`); console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);