From 95d7916530428ceebfabd827b1a7e8927e57ccdc Mon Sep 17 00:00:00 2001 From: Joel Claw <274497724+Joel-Claw@users.noreply.github.com> Date: Tue, 26 May 2026 08:05:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(channels):=20normalize=20known=20channel=20?= =?UTF-8?q?display=20names=20(public=20=E2=86=92=20Public)=20(#777)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/ingestor/.gitignore | 1 + cmd/ingestor/db.go | 20 ++++++ cmd/ingestor/decoder.go | 18 ++++- cmd/ingestor/main.go | 20 +++++- cmd/ingestor/normalize_channel_test.go | 97 ++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 cmd/ingestor/.gitignore create mode 100644 cmd/ingestor/normalize_channel_test.go diff --git a/cmd/ingestor/.gitignore b/cmd/ingestor/.gitignore new file mode 100644 index 00000000..8a80aa16 --- /dev/null +++ b/cmd/ingestor/.gitignore @@ -0,0 +1 @@ +ingestor \ No newline at end of file diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 42f9a446..79771f3f 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -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 } diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index 4b9d7928..1c8abc5b 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -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", diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index e1ebd5fa..4f669ae0 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -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 diff --git a/cmd/ingestor/normalize_channel_test.go b/cmd/ingestor/normalize_channel_test.go new file mode 100644 index 00000000..1ced4eea --- /dev/null +++ b/cmd/ingestor/normalize_channel_test.go @@ -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"]) + } +}