fix(channels): normalize known channel display names (public → Public) (#777)

Normalizes well-known channel display names (currently only `public` → `Public`) so existing deployments with pre-#761 lowercase config keys show the canonical firmware-default name `Public` in the UI.

Behavior:
- `knownChannelCasing` lookup (`decoder.go`) — single-entry map, easy to extend.
- `normalizeChannelName()` applied at config load (`loadChannelKeys`) AND at decode time (defense in depth).
- One-shot SQLite migration `channel_hash_casing_v1` backfills `channel_hash='public'` → `'Public'` on `payload_type=5` rows so channel-grouping queries don't split across the upgrade boundary.
- Hardcoded list intentionally tiny (1 entry); custom/user channels left untouched.

Safety:
- Channel-hash derivation (`SHA256(channelName)[:16]` for `#`-prefixed `HashChannels`) is unchanged — normalization only renames map keys for explicit `ChannelKeys` entries (which don't feed `deriveHashtagChannelKey`).
- PSK lookup is by hash byte, not by name — mesh interop preserved.
- Migration is gated by `_migrations.name='channel_hash_casing_v1'`, idempotent.

Tests (`cmd/ingestor/normalize_channel_test.go`):
- `TestNormalizeChannelName` covers known + hashtag + custom + empty.
- `TestLoadChannelKeys_NormalizesKnownDisplayNames` — verifies `public` → `Public` at load.
- `TestLoadChannelKeys_LeavesCustomNamesUntouched` — custom names not auto-capitalized.
- `TestLoadChannelKeys_DuplicateCasingLogsWarning` — config containing both casings resolves deterministically (canonical wins).

Mutation test confirmed: reverting load-time normalize → `TestLoadChannelKeys_NormalizesKnownDisplayNames` and `_DuplicateCasingLogsWarning` both fail on assertions.

Related: #761
This commit is contained in:
Joel Claw
2026-05-26 08:05:07 +02:00
committed by GitHub
parent c70f4b1c3d
commit 95d7916530
5 changed files with 154 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
ingestor
+20
View File
@@ -556,6 +556,26 @@ func applySchema(db *sql.DB) error {
// this column as hasDefaultScope; keeping a single canonical Apply
// path closes the startup race that #1321 documented.
// Migration: normalize known channel_hash values for existing rows.
// Before this PR, config key "public" was stored as channel_hash="public".
// After this PR, new rows use channel_hash="Public". Without backfill,
// channel grouping queries split into two buckets across the upgrade boundary.
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'channel_hash_casing_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Normalizing known channel_hash values...")
res, err := db.Exec(`UPDATE transmissions SET channel_hash = 'Public' WHERE channel_hash = 'public' AND payload_type = 5`)
if err != nil {
log.Printf("[migration] ERROR: failed to normalize channel_hash: %v", err)
return fmt.Errorf("migration channel_hash_casing_v1 UPDATE failed: %w", err)
}
n, _ := res.RowsAffected()
log.Printf("[migration] Normalized %d channel_hash rows from 'public' to 'Public'", n)
if _, err := db.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('channel_hash_casing_v1')`); err != nil {
log.Printf("[migration] WARNING: failed to record migration: %v", err)
}
log.Println("[migration] channel_hash casing normalization complete")
}
return nil
}
+17 -1
View File
@@ -493,6 +493,22 @@ func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channe
return result, nil
}
// knownChannelCasing maps known channel keys to their canonical display names.
// Only well-known channels are normalized — custom/user channels are left as-is.
var knownChannelCasing = map[string]string{
"public": "Public",
}
// normalizeChannelName fixes casing for well-known channel names.
// Only normalizes names that appear in knownChannelCasing (e.g. "public" → "Public").
// Custom channel names are left untouched since we can't know the intended casing.
func normalizeChannelName(name string) string {
if corrected, ok := knownChannelCasing[strings.ToLower(name)]; ok {
return corrected
}
return name
}
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)}
@@ -517,7 +533,7 @@ func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
}
return Payload{
Type: "CHAN",
Channel: name,
Channel: normalizeChannelName(name),
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decrypted",
+19 -1
View File
@@ -1195,7 +1195,25 @@ func loadChannelKeys(cfg *Config, configPath string) map[string]string {
// 3. Explicit config keys (highest priority — overrides rainbow + derived)
for k, v := range cfg.ChannelKeys {
keys[k] = v
normalized := normalizeChannelName(k)
if normalized != k {
log.Printf("[channels] Normalizing known channel key %q → %q for display", k, normalized)
}
// Detect config collision: if both "public" and "Public" are present,
// the normalized key collides. Resolve deterministically: prefer the
// canonical (already-normalized) form over the lowercase variant.
if _, dupe := keys[normalized]; dupe {
// If the incoming key IS the canonical form, it wins (overwrite).
// If the incoming key is a non-canonical form (e.g., "public"), keep existing.
if k == normalized {
log.Printf("[channels] Resolving duplicate %q: canonical form wins over non-canonical", normalized)
keys[normalized] = v
} else {
log.Printf("[channels] WARNING: duplicate channel key %q — config has %q normalizing to %q, keeping canonical value", normalized, k, normalized)
}
} else {
keys[normalized] = v
}
}
return keys
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"testing"
)
func TestNormalizeChannelName(t *testing.T) {
tests := []struct {
input string
expected string
}{
// Known channel: "public" should be normalized to "Public"
{"public", "Public"},
{"Public", "Public"},
{"PUBLIC", "Public"},
// Hashtag channels should be left untouched
{"#LongFast", "#LongFast"},
{"#wardrive", "#wardrive"},
// Custom/unknown channels should be left untouched
{"myChannel", "myChannel"},
{"testchannel", "testchannel"},
// Empty string
{"", ""},
}
for _, tt := range tests {
got := normalizeChannelName(tt.input)
if got != tt.expected {
t.Errorf("normalizeChannelName(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestLoadChannelKeys_NormalizesKnownDisplayNames(t *testing.T) {
// Verify that known channel keys with wrong casing get normalized
cfg := &Config{
ChannelKeys: map[string]string{
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
},
}
keys := loadChannelKeys(cfg, "/dev/null")
// Should have "Public" (normalized) not "public" (raw)
if _, ok := keys["public"]; ok {
t.Error("Expected 'public' to be normalized to 'Public'")
}
if _, ok := keys["Public"]; !ok {
t.Error("Expected 'Public' key to exist in loaded channel keys")
}
}
func TestLoadChannelKeys_LeavesCustomNamesUntouched(t *testing.T) {
// Verify that custom channel names are NOT normalized
cfg := &Config{
ChannelKeys: map[string]string{
"myCustomChannel": "deadbeef12345678",
},
}
keys := loadChannelKeys(cfg, "/dev/null")
// Should keep "myCustomChannel" as-is
if _, ok := keys["myCustomChannel"]; !ok {
t.Error("Expected 'myCustomChannel' to be left untouched")
}
// Should NOT have "MyCustomChannel"
if _, ok := keys["MyCustomChannel"]; ok {
t.Error("Custom channel names should NOT be auto-capitalized")
}
}
func TestLoadChannelKeys_DuplicateCasingLogsWarning(t *testing.T) {
// Verify that config with both "public" and "Public" resolves deterministically:
// the canonical (already-normalized) form should win.
cfg := &Config{
ChannelKeys: map[string]string{
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
"Public": "differentkey1234567",
},
}
keys := loadChannelKeys(cfg, "/dev/null")
// After normalization, only one key should exist: "Public"
// The canonical form ("Public") should win over the lowercase form ("public")
if _, ok := keys["public"]; ok {
t.Error("Expected 'public' to be normalized away")
}
if _, ok := keys["Public"]; !ok {
t.Error("Expected 'Public' key to exist")
}
// Assert the canonical form's value won, not just any value
if keys["Public"] != "differentkey1234567" {
t.Errorf("Expected canonical 'Public' value to win, got %q", keys["Public"])
}
}