diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 833573c2b..a79c2112e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -142,6 +142,7 @@ jobs: node test-1659-analytics-warmup.js node test-app-api-inflight-cleanup-rejection.js node test-channels-merge-1498-unit.js + node test-channels-observed-path-hash-size.js node test-issue-1518-home-url.js node test-channel-decrypt-insecure-context.js node test-live-region-filter.js @@ -564,6 +565,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-share-color-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-ws-batch-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-ws-race-1498-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-observed-path-hash-size-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1487-byop-modal-layout-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1630-reach-mobile-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-node-reach-coverage-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/cmd/server/chunked_load.go b/cmd/server/chunked_load.go index 4e6d7380f..300d58f47 100644 --- a/cmd/server/chunked_load.go +++ b/cmd/server/chunked_load.go @@ -637,6 +637,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold } } + tx.mergeObservedPathHashSize(obsPJ) tx.Observations = append(tx.Observations, obs) tx.obsKeys[dk] = true if obs.ObserverID != "" && !tx.observerSet[obs.ObserverID] { diff --git a/cmd/server/db.go b/cmd/server/db.go index 37441f492..b3edb1c2b 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3506,9 +3506,10 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . defer rows.Close() type msg struct { - Data map[string]interface{} - Repeats int - LatestEpoch int64 // max observation timestamp (unix seconds) — issue #1366 + Data map[string]interface{} + Repeats int + LatestEpoch int64 // max observation timestamp (unix seconds) — issue #1366 + PathHashSizeMask uint8 } msgMap := make(map[int]*msg, len(pageIDs)) @@ -3582,8 +3583,11 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . observerName = obsID.String } + pathHashSizeMask := observedPathHashSizeMask(nullStrVal(pathJSON)) if existing, ok := msgMap[txID]; ok { existing.Repeats++ + existing.PathHashSizeMask |= pathHashSizeMask + existing.Data["observedPathHashSizes"] = observedPathHashSizes(existing.PathHashSizeMask) if obsTs.Valid && obsTs.Int64 > existing.LatestEpoch { existing.LatestEpoch = obsTs.Int64 } @@ -3638,23 +3642,25 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } m := &msg{ Data: map[string]interface{}{ - "sender": displaySender, - "text": displayText, - "timestamp": nullStr(fs), - "first_seen": nullStr(fs), - "sender_timestamp": senderTs, - "packetId": pktID, - "packetHash": nullStr(pktHash), - "repeats": 1, - "observers": []string{}, - "hops": hops, - "snr": nullFloat(snr), - "scope": nullStr(scopeName), - "routeType": nullInt(routeType), - "entryPrefix": entryPrefix, - "entryObserverPubkey": entryObserverPubkey, + "sender": displaySender, + "text": displayText, + "timestamp": nullStr(fs), + "first_seen": nullStr(fs), + "sender_timestamp": senderTs, + "packetId": pktID, + "packetHash": nullStr(pktHash), + "repeats": 1, + "observers": []string{}, + "hops": hops, + "snr": nullFloat(snr), + "scope": nullStr(scopeName), + "routeType": nullInt(routeType), + "entryPrefix": entryPrefix, + "entryObserverPubkey": entryObserverPubkey, + "observedPathHashSizes": observedPathHashSizes(pathHashSizeMask), }, - Repeats: 1, + Repeats: 1, + PathHashSizeMask: pathHashSizeMask, } if obsTs.Valid { m.LatestEpoch = obsTs.Int64 diff --git a/cmd/server/hash_migrate.go b/cmd/server/hash_migrate.go index cd5929221..c76ca9357 100644 --- a/cmd/server/hash_migrate.go +++ b/cmd/server/hash_migrate.go @@ -22,6 +22,17 @@ func migrateContentHashesAsync(store *PacketStore, batchSize int, yieldDuration total := len(store.packets) store.mu.RUnlock() + // Keep evidence only for hashes that actually collide during this one + // migration run. The legacy migration retains duplicate in-memory rows, so + // a later batch must also update ghosts created by an earlier collision. + // This registry is bounded by collision members and is discarded when the + // migration returns. + type collisionEvidence struct { + mask uint8 + members map[*StoreTx]struct{} + } + collisionEvidenceByHash := make(map[string]*collisionEvidence) + migrated := 0 for offset := 0; offset < total; offset += batchSize { end := offset + batchSize @@ -52,6 +63,12 @@ func migrateContentHashesAsync(store *PacketStore, batchSize int, yieldDuration if len(updates) == 0 { continue } + // A UNIQUE collision merges DB observations into one survivor, while + // this legacy migration intentionally keeps its existing in-memory + // cardinality/index behaviour. Track the survivor IDs so the observed + // path-width evidence can nevertheless be made consistent across every + // same-content in-memory row after the existing update loop. + collisionSurvivors := make(map[string][]int) // Write batch to DB in a single transaction. dbTx, err := store.db.conn.Begin() @@ -75,6 +92,7 @@ func migrateContentHashesAsync(store *PacketStore, batchSize int, yieldDuration if err2 := dbTx.QueryRow("SELECT id FROM transmissions WHERE hash = ?", u.newHash).Scan(&survID); err2 == nil { dbTx.Exec("UPDATE observations SET transmission_id = ? WHERE transmission_id = ?", survID, u.tx.ID) dbTx.Exec("DELETE FROM transmissions WHERE id = ?", u.tx.ID) + collisionSurvivors[u.newHash] = append(collisionSurvivors[u.newHash], survID) u.newHash = "" // mark for in-memory removal only } } @@ -105,6 +123,32 @@ func migrateContentHashesAsync(store *PacketStore, batchSize int, yieldDuration store.byHash[u.newHash] = u.tx } } + for newHash, survivorIDs := range collisionSurvivors { + evidence := collisionEvidenceByHash[newHash] + if evidence == nil { + evidence = &collisionEvidence{members: make(map[*StoreTx]struct{})} + collisionEvidenceByHash[newHash] = evidence + } + addMember := func(tx *StoreTx) { + if tx == nil { + return + } + evidence.mask |= tx.pathHashSizeMask + evidence.members[tx] = struct{}{} + } + for _, u := range updates { + if u.newHash == newHash { + addMember(u.tx) + } + } + for _, survivorID := range survivorIDs { + addMember(store.byTxID[survivorID]) + } + addMember(store.byHash[newHash]) + for member := range evidence.members { + member.pathHashSizeMask |= evidence.mask + } + } store.mu.Unlock() migrated += len(updates) diff --git a/cmd/server/hash_migrate_test.go b/cmd/server/hash_migrate_test.go index f1c816de9..4868d3de5 100644 --- a/cmd/server/hash_migrate_test.go +++ b/cmd/server/hash_migrate_test.go @@ -1,6 +1,7 @@ package main import ( + "reflect" "testing" "time" ) @@ -76,3 +77,202 @@ func TestMigrateContentHashesAsync_NoOp(t *testing.T) { t.Error("hash should remain in index") } } + +func TestMigrateContentHashesAsync_CollisionUnionsObservedPathHashSizesWithoutChangingLegacyIndexes(t *testing.T) { + db := setupTestDBv2(t) + store := NewPacketStore(db, nil) + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + correctHash := ComputeContentHash(rawHex) + for _, row := range []struct { + id int + wrongHash string + firstSeen string + pathJSON string + }{ + {1, "old-hash-one", "2026-01-01T00:00:00Z", `["AA"]`}, + {2, "old-hash-two", "2026-01-01T00:00:01Z", `["BEEF"]`}, + } { + if _, err := db.conn.Exec(`INSERT INTO transmissions + (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES (?, ?, ?, ?, 1, 5, '{}')`, row.id, rawHex, row.wrongHash, row.firstSeen); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`INSERT INTO observations + (id, transmission_id, observer_id, observer_name, path_json, timestamp) + VALUES (?, ?, ?, ?, ?, ?)`, row.id, row.id, "observer", "Observer", row.pathJSON, row.id); err != nil { + t.Fatal(err) + } + } + + if err := store.Load(); err != nil { + t.Fatal(err) + } + // One row per batch proves that the survivor's evidence is recovered from + // byTxID rather than only from same-batch updates. + migrateContentHashesAsync(store, 1, 0) + + wantSizes := []int{1, 2} + for _, tx := range store.packets { + if got := tx.observedPathHashSizes(); !reflect.DeepEqual(got, wantSizes) { + t.Fatalf("tx %d observed path hash sizes = %v, want %v", tx.ID, got, wantSizes) + } + } + if authoritative := store.byHash[correctHash]; authoritative == nil || + !reflect.DeepEqual(authoritative.observedPathHashSizes(), wantSizes) { + t.Fatalf("authoritative observed path hash sizes = %v, want %v", + authoritative.observedPathHashSizes(), wantSizes) + } + + // Characterize, do not silently fix, the pre-existing duplicate-migration + // index bug. A separate change must remove the deleted row from every + // PacketStore index and re-parent its observations atomically. + if len(store.packets) != 2 || len(store.byPayloadType[PayloadGRP_TXT]) != 2 { + t.Fatalf("legacy in-memory cardinality changed: packets/payload = %d/%d, want 2/2", + len(store.packets), len(store.byPayloadType[PayloadGRP_TXT])) + } + if got := store.QueryPackets(PacketQuery{Limit: 10}).Total; got != 2 { + t.Fatalf("legacy QueryPackets cardinality changed: got %d, want 2", got) + } + if store.byTxID[2] == nil || store.byTxID[2].Observations[0].TransmissionID != 2 { + t.Fatal("legacy duplicate/observation ownership changed unexpectedly") + } + + var txCount, canonicalObservationCount int + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM transmissions WHERE raw_hex = ?`, rawHex).Scan(&txCount); err != nil { + t.Fatal(err) + } + if txCount != 1 { + t.Fatalf("DB transmission rows = %d, want 1 after duplicate merge", txCount) + } + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM observations WHERE transmission_id = 1`).Scan(&canonicalObservationCount); err != nil { + t.Fatal(err) + } + if canonicalObservationCount != 2 { + t.Fatalf("DB canonical observations = %d, want 2 after duplicate merge", canonicalObservationCount) + } +} + +func TestMigrateContentHashesAsync_CollisionIncludesAlreadyCurrentSurvivorEvidence(t *testing.T) { + db := setupTestDBv2(t) + store := NewPacketStore(db, nil) + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + correctHash := ComputeContentHash(rawHex) + for _, row := range []struct { + id int + hash string + pathJSON string + }{ + {1, correctHash, `["010203"]`}, + {2, "old-hash", `["BEEF"]`}, + } { + if _, err := db.conn.Exec(`INSERT INTO transmissions + (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES (?, ?, ?, ?, 1, 5, '{}')`, row.id, rawHex, row.hash, + time.Date(2026, 1, 1, 0, 0, row.id, 0, time.UTC).Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`INSERT INTO observations + (id, transmission_id, observer_id, observer_name, path_json, timestamp) + VALUES (?, ?, ?, ?, ?, ?)`, row.id, row.id, "observer", "Observer", row.pathJSON, row.id); err != nil { + t.Fatal(err) + } + } + + if err := store.Load(); err != nil { + t.Fatal(err) + } + migrateContentHashesAsync(store, 100, 0) + + want := []int{2, 3} + for _, tx := range store.packets { + if got := tx.observedPathHashSizes(); !reflect.DeepEqual(got, want) { + t.Fatalf("tx %d observed path hash sizes = %v, want %v", tx.ID, got, want) + } + } + if got := store.byHash[correctHash].observedPathHashSizes(); !reflect.DeepEqual(got, want) { + t.Fatalf("authoritative observed path hash sizes = %v, want %v", got, want) + } + if len(store.packets) != 2 || store.QueryPackets(PacketQuery{Limit: 10}).Total != 2 { + t.Fatal("legacy in-memory duplicate cardinality changed unexpectedly") + } +} + +func TestMigrateContentHashesAsync_CollisionCarriesEvidenceAcrossBatches(t *testing.T) { + db := setupTestDBv2(t) + store := NewPacketStore(db, nil) + + rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" + correctHash := ComputeContentHash(rawHex) + for _, row := range []struct { + id int + wrongHash string + pathJSON string + }{ + {1, "old-hash-one", `["AA"]`}, + {2, "old-hash-two", `["BEEF"]`}, + {3, "old-hash-three", `["010203"]`}, + } { + if _, err := db.conn.Exec(`INSERT INTO transmissions + (id, raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES (?, ?, ?, ?, 1, 5, '{}')`, row.id, rawHex, row.wrongHash, + time.Date(2026, 1, 1, 0, 0, row.id, 0, time.UTC).Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + if _, err := db.conn.Exec(`INSERT INTO observations + (id, transmission_id, observer_id, observer_name, path_json, timestamp) + VALUES (?, ?, ?, ?, ?, ?)`, row.id, row.id, "observer", "Observer", row.pathJSON, row.id); err != nil { + t.Fatal(err) + } + } + + if err := store.Load(); err != nil { + t.Fatal(err) + } + // Force each collision into a different transaction/batch. Evidence learned + // by the third collision must flow back into the ghost row retained by the + // second collision's legacy in-memory behaviour. + migrateContentHashesAsync(store, 1, 0) + + want := []int{1, 2, 3} + for _, tx := range store.packets { + if got := tx.observedPathHashSizes(); !reflect.DeepEqual(got, want) { + t.Fatalf("tx %d observed path hash sizes = %v, want %v", tx.ID, got, want) + } + } + if got := store.byHash[correctHash].observedPathHashSizes(); !reflect.DeepEqual(got, want) { + t.Fatalf("authoritative observed path hash sizes = %v, want %v", got, want) + } + + // Keep characterizing the separate legacy index bug rather than hiding it + // inside this evidence-only feature change. + if len(store.packets) != 3 || len(store.byPayloadType[PayloadGRP_TXT]) != 3 { + t.Fatalf("legacy in-memory cardinality changed: packets/payload = %d/%d, want 3/3", + len(store.packets), len(store.byPayloadType[PayloadGRP_TXT])) + } + if got := store.QueryPackets(PacketQuery{Limit: 10}).Total; got != 3 { + t.Fatalf("legacy QueryPackets cardinality changed: got %d, want 3", got) + } + for _, duplicateID := range []int{2, 3} { + duplicate := store.byTxID[duplicateID] + if duplicate == nil || len(duplicate.Observations) != 1 || + duplicate.Observations[0].TransmissionID != duplicateID { + t.Fatalf("legacy duplicate %d observation ownership changed unexpectedly", duplicateID) + } + } + + var txCount, canonicalObservationCount int + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM transmissions WHERE raw_hex = ?`, rawHex).Scan(&txCount); err != nil { + t.Fatal(err) + } + if txCount != 1 { + t.Fatalf("DB transmission rows = %d, want 1 after duplicate merges", txCount) + } + if err := db.conn.QueryRow(`SELECT COUNT(*) FROM observations WHERE transmission_id = 1`).Scan(&canonicalObservationCount); err != nil { + t.Fatal(err) + } + if canonicalObservationCount != 3 { + t.Fatalf("DB canonical observations = %d, want 3 after duplicate merges", canonicalObservationCount) + } +} diff --git a/cmd/server/observed_path_hash_sizes.go b/cmd/server/observed_path_hash_sizes.go new file mode 100644 index 000000000..dd5e9df28 --- /dev/null +++ b/cmd/server/observed_path_hash_sizes.go @@ -0,0 +1,110 @@ +package main + +const observedPathHashSizeMaskAll uint8 = 0b111 + +// observedPathHashSizeMask returns one evidence bit for a persisted +// observation path. A path is evidence only when it is non-empty and every +// hop is hexadecimal, has the same width, and is exactly 1, 2, or 3 bytes. +// Empty/direct paths and malformed or mixed-width paths are deliberately +// unknown: they must not be interpreted as a sender setting. +func observedPathHashSizeMask(pathJSON string) uint8 { + i := 0 + skipJSONSpace(pathJSON, &i) + if i >= len(pathJSON) || pathJSON[i] != '[' { + return 0 + } + i++ + skipJSONSpace(pathJSON, &i) + if i >= len(pathJSON) || pathJSON[i] == ']' { + return 0 + } + + width := 0 + for { + skipJSONSpace(pathJSON, &i) + if i >= len(pathJSON) || pathJSON[i] != '"' { + return 0 + } + i++ + start := i + for i < len(pathJSON) && pathJSON[i] != '"' { + if !isHexByte(pathJSON[i]) { + return 0 + } + i++ + } + if i >= len(pathJSON) || i == start { + return 0 + } + hopWidth := i - start + if hopWidth != 2 && hopWidth != 4 && hopWidth != 6 { + return 0 + } + if width == 0 { + width = hopWidth + } else if hopWidth != width { + return 0 + } + i++ + skipJSONSpace(pathJSON, &i) + if i >= len(pathJSON) { + return 0 + } + switch pathJSON[i] { + case ',': + i++ + case ']': + i++ + skipJSONSpace(pathJSON, &i) + if i != len(pathJSON) { + return 0 + } + return 1 << (uint(width/2) - 1) + default: + return 0 + } + } +} + +func skipJSONSpace(s string, i *int) { + for *i < len(s) { + switch s[*i] { + case ' ', '\t', '\n', '\r': + *i = *i + 1 + default: + return + } + } +} + +func isHexByte(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +// observedPathHashSizes expands a compact evidence mask into the stable API +// order. It always returns a non-nil slice so unknown evidence serializes as +// [] rather than null. +func observedPathHashSizes(mask uint8) []int { + mask &= observedPathHashSizeMaskAll + sizes := make([]int, 0, 3) + for size := 1; size <= 3; size++ { + if mask&(1< 0 { m["_parsedPath"] = hops @@ -5829,20 +5851,21 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, entry := &msgEntry{ Data: map[string]interface{}{ - "sender": displaySender, - "text": displayText, - "timestamp": strOrNil(displayTs), - "first_seen": strOrNil(tx.FirstSeen), - "sender_timestamp": senderTs, - "packetId": tx.ID, - "packetHash": strOrNil(tx.Hash), - "repeats": 1, - "observers": observers, - "hops": hops, - "snr": snrVal, - "scope": strOrNil(tx.ScopeName), - "routeType": intPtrOrNil(tx.RouteType), - "entryPrefix": pathFirstHop(tx.PathJSON), + "sender": displaySender, + "text": displayText, + "timestamp": strOrNil(displayTs), + "first_seen": strOrNil(tx.FirstSeen), + "sender_timestamp": senderTs, + "packetId": tx.ID, + "packetHash": strOrNil(tx.Hash), + "repeats": 1, + "observers": observers, + "hops": hops, + "snr": snrVal, + "scope": strOrNil(tx.ScopeName), + "routeType": intPtrOrNil(tx.RouteType), + "entryPrefix": pathFirstHop(tx.PathJSON), + "observedPathHashSizes": tx.observedPathHashSizes(), }, Repeats: 1, Observers: observers, diff --git a/docs/api-spec.md b/docs/api-spec.md index e38c72403..6ce429d45 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -1336,13 +1336,21 @@ Messages for a specific channel. "repeats": number, // dedup count "observers": [string], // observer names "hops": number, - "snr": number | null + "snr": number | null, + "observedPathHashSizes": [number] // sorted unique relayed path widths (1–3) } ], "total": number // total deduplicated messages } ``` +`observedPathHashSizes` aggregates evidence from the message's observations. +It contains only hash widths encoded by non-empty relayed wire paths. Direct +zero-hop copies provide no hash-size evidence and do not add a value. More than +one value means different widths were observed for the same deduplicated +message; the field describes those observations, not the sender's permanent +configuration. + --- ## Shared channel proposals @@ -2336,6 +2344,7 @@ Broadcast on every new packet ingestion. | `data.packet` | | ✓ | | | | `data.observation_count` | | ✓ | | | | `data.path_json` | ✓ | | | | +| `data.observed_path_hash_sizes` | | | | ✓ | | (any) | | | ✓ (*) | | (*) `app.js` passes all messages to registered `wsListeners` and uses them only for cache invalidation. @@ -2379,6 +2388,7 @@ A transmission/packet as stored in memory and returned by most endpoints: "snr": number | null, "rssi": number | null, "path_json": string | null, // JSON-stringified hop array + "observed_path_hash_sizes": [number] | undefined, // sorted unique relayed path widths (1–3) "direction": string | null, "score": number | null, "observations": [Observation] | undefined // stripped by default on list endpoints diff --git a/public/channels.js b/public/channels.js index dd12fd335..10b9e2411 100644 --- a/public/channels.js +++ b/public/channels.js @@ -7,6 +7,97 @@ let messages = []; let wsHandler = null; + var OBSERVED_PATH_HASH_TOOLTIP = 'Path hash size observed in one or more relayed wire paths for this message. Direct zero-hop copies do not provide hash-size evidence. This does not prove the sender’s permanent configuration.'; + + // Normalize the two API spellings at the browser boundary. Only the three + // MeshCore path-hash widths are evidence; direct/zero-hop and malformed + // values intentionally collapse to an empty set (no badge). + function normalizeObservedPathHashSizes(source) { + var values = []; + if (Array.isArray(source)) { + values = source; + } else if (source && typeof source === 'object') { + if (Array.isArray(source.observedPathHashSizes)) values = values.concat(source.observedPathHashSizes); + if (Array.isArray(source.observed_path_hash_sizes)) values = values.concat(source.observed_path_hash_sizes); + } + + var seen = new Set(); + for (var i = 0; i < values.length; i++) { + var value = values[i]; + if (typeof value !== 'number' && typeof value !== 'string') continue; + var text = String(value).trim(); + if (!/^[123]$/.test(text)) continue; + seen.add(Number(text)); + } + return Array.from(seen).sort(function (a, b) { return a - b; }); + } + + function unionObservedPathHashSizes() { + var seen = new Set(); + for (var i = 0; i < arguments.length; i++) { + var sizes = normalizeObservedPathHashSizes(arguments[i]); + for (var j = 0; j < sizes.length; j++) seen.add(sizes[j]); + } + return Array.from(seen).sort(function (a, b) { return a - b; }); + } + + function withObservedPathHashSizes(message, extraEvidence) { + if (!message || typeof message !== 'object') return message; + var sizes = unionObservedPathHashSizes(message, extraEvidence); + if (!sizes.length) return message; + var copy = Object.assign({}, message); + copy.observedPathHashSizes = sizes; + return copy; + } + + function renderObservedPathHashBadge(message) { + var sizes = normalizeObservedPathHashSizes(message); + if (!sizes.length) return ''; + var label = sizes.length === 1 + ? 'Observed path hash: ' + sizes[0] + '-byte' + : 'Mixed path hashes: ' + sizes.join('/') + '-byte'; + return '' + escapeHtml(label) + ''; + } + + // Client-decrypted messages are cached with their plaintext. A later API + // response can carry new path evidence for the same transmission without + // changing its candidate count or first_seen timestamp, so fold that + // metadata into the cached row before the delta-fetch early return. Keep + // the cached row canonical: replacing it with the packet candidate would + // discard the locally decrypted sender/text. + function reconcileCandidateEvidenceIntoCache(cachedMsgs, candidates) { + if (!Array.isArray(cachedMsgs) || !cachedMsgs.length || !Array.isArray(candidates)) { + return { messages: cachedMsgs, changed: false }; + } + + var evidenceByHash = new Map(); + for (var i = 0; i < candidates.length; i++) { + var packet = candidates[i] && candidates[i].packet; + var packetHash = packet && packet.hash; + if (!packetHash) continue; + evidenceByHash.set( + packetHash, + unionObservedPathHashSizes(evidenceByHash.get(packetHash), packet)); + } + + var merged = cachedMsgs; + var changed = false; + for (var j = 0; j < cachedMsgs.length; j++) { + var cachedMessage = cachedMsgs[j]; + var candidateEvidence = cachedMessage && evidenceByHash.get(cachedMessage.packetHash); + if (!candidateEvidence || !candidateEvidence.length) continue; + var before = normalizeObservedPathHashSizes(cachedMessage); + var after = unionObservedPathHashSizes(before, candidateEvidence); + if (before.length === after.length && before.every(function (size, index) { return size === after[index]; })) { + continue; + } + if (!changed) merged = cachedMsgs.slice(); + merged[j] = withObservedPathHashSizes(cachedMessage, candidateEvidence); + changed = true; + } + return { messages: merged, changed: changed }; + } + // #1498: messages appended via the live WebSocket are stamped with // _fromWS so a subsequent REST replacement (selectChannel / // refreshMessages) can merge them in instead of stomping them. @@ -33,11 +124,36 @@ var MAX_WS_SURVIVOR_MS = 5 * 60 * 1000; // 5 minutes function mergeWsAppendedIntoRest(currentMsgs, restMsgs) { if (!Array.isArray(restMsgs)) return []; - if (!Array.isArray(currentMsgs) || currentMsgs.length === 0) return restMsgs.slice(); + if (!Array.isArray(currentMsgs)) currentMsgs = []; + var currentEvidenceByHash = new Map(); + for (var c = 0; c < currentMsgs.length; c++) { + var current = currentMsgs[c]; + if (!current || !current.packetHash) continue; + currentEvidenceByHash.set( + current.packetHash, + unionObservedPathHashSizes(currentEvidenceByHash.get(current.packetHash), current)); + } + + var restEvidenceByHash = new Map(); + for (var r = 0; r < restMsgs.length; r++) { + var restItem = restMsgs[r]; + if (!restItem || !restItem.packetHash) continue; + restEvidenceByHash.set( + restItem.packetHash, + unionObservedPathHashSizes(restEvidenceByHash.get(restItem.packetHash), restItem)); + } + + // slice() deliberately preserves the caller's Array realm. Besides + // keeping this helper transparent to consumers, it avoids surprising + // prototype changes when exercised through the VM-based unit harness. + var mergedRest = restMsgs.slice(); var restHashes = new Set(); for (var i = 0; i < restMsgs.length; i++) { var h = restMsgs[i] && restMsgs[i].packetHash; if (h) restHashes.add(h); + mergedRest[i] = withObservedPathHashSizes( + restMsgs[i], + h ? unionObservedPathHashSizes(currentEvidenceByHash.get(h), restEvidenceByHash.get(h)) : null); } var now = Date.now(); var survivors = []; @@ -49,11 +165,11 @@ // If packetHash present and REST contains it, REST wins (drop). if (m.packetHash && restHashes.has(m.packetHash)) continue; // Hash absent OR not in REST → preserve. - survivors.push(m); + survivors.push(withObservedPathHashSizes(m)); } // Always return a fresh array — never alias restMsgs — so callers // can mutate freely without leaking changes back to the input. - return survivors.length ? restMsgs.concat(survivors) : restMsgs.slice(); + return survivors.length ? mergedRest.concat(survivors) : mergedRest; } let autoScroll = true; let nodeCache = {}; @@ -608,6 +724,9 @@ // M5: Delta fetch — only decrypt packets newer than lastTs if (!needFullDecrypt && cachedMsgs.length > 0 && lastTs) { + var reconciledCache = reconcileCandidateEvidenceIntoCache(cachedMsgs, candidates); + cachedMsgs = reconciledCache.messages; + // Filter candidates to only those newer than cached lastTimestamp var newCandidates = candidates.filter(function (c) { var ts = c.packet.first_seen || c.packet.timestamp || ''; @@ -615,13 +734,20 @@ }); if (newCandidates.length === 0) { - // Nothing new — return cache as-is + // Nothing new to decrypt. Persist only when the API enriched the + // evidence so legacy caches gain the badge on this render. + if (reconciledCache.changed) { + ChannelDecrypt.setCache(cacheKey, cachedMsgs, lastTs, totalCandidates); + } return { messages: cachedMsgs, fromCache: true }; } // Decrypt only new candidates var newDecrypted = await decryptCandidates(keyBytes, newCandidates); if (newDecrypted.wrongKey) { + if (reconciledCache.changed) { + ChannelDecrypt.setCache(cacheKey, cachedMsgs, lastTs, totalCandidates); + } return { messages: cachedMsgs, wrongKey: true }; } @@ -691,6 +817,7 @@ observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, + observedPathHashSizes: normalizeObservedPathHashSizes(c.packet), repeats: 1, botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver) }); @@ -711,6 +838,7 @@ observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, + observedPathHashSizes: normalizeObservedPathHashSizes(c.packet), repeats: 1, botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver) }); @@ -727,17 +855,29 @@ /** Merge cached and new messages, deduplicate by packetHash, sort chronologically. */ function deduplicateAndMerge(cached, newMsgs) { - var seen = {}; + var seen = new Map(); var merged = []; // Add cached first for (var i = 0; i < cached.length; i++) { var key = cached[i].packetHash || ('idx:' + i); - if (!seen[key]) { seen[key] = true; merged.push(cached[i]); } + if (!seen.has(key)) { + seen.set(key, merged.length); + merged.push(withObservedPathHashSizes(cached[i])); + } else { + var cachedIndex = seen.get(key); + merged[cachedIndex] = withObservedPathHashSizes(merged[cachedIndex], cached[i]); + } } // Add new for (var j = 0; j < newMsgs.length; j++) { var key2 = newMsgs[j].packetHash || ('new:' + j); - if (!seen[key2]) { seen[key2] = true; merged.push(newMsgs[j]); } + if (!seen.has(key2)) { + seen.set(key2, merged.length); + merged.push(withObservedPathHashSizes(newMsgs[j])); + } else { + var existingIndex = seen.get(key2); + merged[existingIndex] = withObservedPathHashSizes(merged[existingIndex], newMsgs[j]); + } } merged.sort(function (a, b) { var ta = a.timestamp || ''; @@ -1477,6 +1617,10 @@ var observer = m.data?.packet?.observer_name || m.data?.observer || null; var scope = m.data?.scope_name || m.data?.packet?.scope_name || null; var routeType = m.data?.route_type ?? m.data?.packet?.route_type ?? null; + var observedPathHashSizes = unionObservedPathHashSizes( + m.data, + m.data?.packet, + payload); // Same path[0]-resolved area as the REST message list (server-side // resolveEntryPointArea, see store.go) -- already computed at // broadcast time, just read it here. @@ -1515,6 +1659,7 @@ if (observer && existing.observers && existing.observers.indexOf(observer) === -1) { existing.observers.push(observer); } + existing.observedPathHashSizes = unionObservedPathHashSizes(existing, observedPathHashSizes); // #1498 round-1 finding #2: a WS-arriving observer update on a // REST-loaded message must be stamped so the next REST tick // doesn't stomp it. Without this, the new observer disappears. @@ -1536,6 +1681,7 @@ scope: scope, routeType: routeType, area: area, + observedPathHashSizes: observedPathHashSizes, botReply: pingBotReply(displayText, wsHops, snr, observer), // #1498: mark as WS-pushed so a later REST replacement // (selectChannel / refreshMessages) can merge instead of @@ -2305,11 +2451,23 @@ // last element of `messages` is the newest item — same convention // for REST and for the merged array. _getLastId remains correct. var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; }; - if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return; + // Merge before change detection: a delayed REST row can have the same + // ID/count as the live row while carrying less path-hash evidence. + // Comparing the merged evidence prevents REST from erasing it without + // forcing a needless re-render on every later poll. + var mergedMessages = mergeWsAppendedIntoRest(messages, newMsgs); + var _getEvidenceSignature = function (arr) { + return arr.map(function (m) { + return String((m && m.packetHash) || '') + ':' + normalizeObservedPathHashSizes(m).join(','); + }).join('|'); + }; + if (mergedMessages.length === messages.length && + _getLastId(mergedMessages) === _getLastId(messages) && + _getEvidenceSignature(mergedMessages) === _getEvidenceSignature(messages)) return; var prevLen = messages.length; // #1498: merge WS-pushed messages so a refresh that races a live // packet doesn't wipe it. - messages = mergeWsAppendedIntoRest(messages, newMsgs); + messages = mergedMessages; renderMessages(); if (wasAtBottom) scrollToBottom(); else { @@ -2372,6 +2530,7 @@ // (unique_prefix) to a positioned node; omitted otherwise, not // guessed. if (msg.area) meta.push(`area: ${escapeHtml(msg.area)}`); + const pathHashBadgeHtml = renderObservedPathHashBadge(msg); const safeId = btoa(encodeURIComponent(sender)); @@ -2410,7 +2569,7 @@
${displayText}
-
${meta.join(' · ')}${msg.packetHash ? ` · View packet → · ` : ''}
+
${pathHashBadgeHtml}${pathHashBadgeHtml && meta.length ? ' · ' : ''}${meta.join(' · ')}${msg.packetHash ? ` · View packet → · ` : ''}
${botReplyHtml}`; }).join(''); @@ -2436,6 +2595,10 @@ window._channelsSelectChannelForTest = selectChannel; window._channelsRefreshMessagesForTest = refreshMessages; window._channelsMergeWsAppendedIntoRestForTest = mergeWsAppendedIntoRest; + window._channelsNormalizeObservedPathHashSizesForTest = normalizeObservedPathHashSizes; + window._channelsUnionObservedPathHashSizesForTest = unionObservedPathHashSizes; + window._channelsRenderObservedPathHashBadgeForTest = renderObservedPathHashBadge; + window._channelsDeduplicateAndMergeForTest = deduplicateAndMerge; window._channelsLoadChannelsForTest = loadChannels; window._channelsRenderChannelRowForTest = renderChannelRow; window._channelsTickChannelTimesForTest = tickChannelTimes; diff --git a/public/style.css b/public/style.css index aeae50c51..4425f2696 100644 --- a/public/style.css +++ b/public/style.css @@ -1937,6 +1937,18 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .ch-bot-message .ch-msg-bubble { border-style: dashed; font-style: italic; } .ch-encrypted-text { font-size: 11px; color: var(--text-muted); } .ch-msg-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; } +.ch-path-hash-badge { + display: inline-flex; + max-width: 100%; + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: var(--badge-radius); + background: var(--surface-2); + color: var(--text-muted); + line-height: 1.4; + overflow-wrap: anywhere; + vertical-align: baseline; +} .ch-analyze-link { color: var(--link-color); text-decoration: none; margin-left: 8px; } .ch-analyze-link:hover { text-decoration: underline; } .ch-scroll-btn { diff --git a/test-all.sh b/test-all.sh index 884db1880..5c4e9bafb 100755 --- a/test-all.sh +++ b/test-all.sh @@ -96,6 +96,7 @@ node test-issue-1849-trace-hashbytes.js node test-node-analytics-hop-chart.js node test-analytics-hop-depth-ui.js node test-channels-ping-bot-reply.js +node test-channels-observed-path-hash-size.js node test-packet-path-map.js node test-area-nodes-map.js node test-ping-scores.js diff --git a/test-channels-observed-path-hash-size-e2e.js b/test-channels-observed-path-hash-size-e2e.js new file mode 100644 index 000000000..a60a6c404 --- /dev/null +++ b/test-channels-observed-path-hash-size-e2e.js @@ -0,0 +1,213 @@ +/** + * Browser regression for observed path-hash-size evidence on channel messages. + * + * Uses channels.js' real state/render/WS hooks against the local test server; + * no production or external host is contacted. + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const TOOLTIP = 'Path hash size observed in one or more relayed wire paths for this message. Direct zero-hop copies do not provide hash-size evidence. This does not prove the sender’s permanent configuration.'; + +let passed = 0; +let failed = 0; +async function step(name, fn) { + try { + await fn(); + passed++; + console.log(' ✓ ' + name); + } catch (e) { + failed++; + console.error(' ✗ ' + name + ': ' + e.message); + } +} +function assert(condition, message) { + if (!condition) throw new Error(message || 'assertion failed'); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + page.setDefaultTimeout(8000); + + console.log('\n=== channel observed path-hash-size browser regression ==='); + + await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => + typeof window._channelsRenderMessagesForTest === 'function' && + typeof window._channelsProcessWSBatchForTest === 'function'); + + async function render(messages) { + await page.evaluate((items) => { + window._channelsSetStateForTest({ + channels: [{ hash: '#hash-evidence', name: '#hash-evidence', messageCount: items.length }], + messages: items, + selectedHash: '#hash-evidence', + }); + document.querySelector('.ch-layout')?.classList.add('ch-detail-open'); + window._channelsRenderMessagesForTest(); + }, messages); + } + + const baseMessage = { + sender: 'EvidenceNode', + text: 'hello', + timestamp: '2026-09-28T08:00:00Z', + packetHash: 'evidence-hash', + observers: [], + repeats: 1, + }; + + await step('single known size renders the exact conservative badge and tooltip', async () => { + await render([{ ...baseMessage, observedPathHashSizes: [2] }]); + const badge = page.locator('.ch-path-hash-badge'); + assert(await badge.count() === 1, 'expected one badge'); + assert(await badge.textContent() === 'Observed path hash: 2-byte', 'wrong single-size label'); + assert(await badge.getAttribute('title') === TOOLTIP, 'tooltip wording drifted'); + }); + + await step('mixed evidence is sorted and unknown evidence has no badge', async () => { + await render([ + { ...baseMessage, packetHash: 'mixed', observed_path_hash_sizes: [3, 1, 2] }, + { ...baseMessage, packetHash: 'unknown', observedPathHashSizes: [] }, + ]); + const badges = page.locator('.ch-path-hash-badge'); + assert(await badges.count() === 1, 'unknown message must not render a badge'); + assert(await badges.first().textContent() === 'Mixed path hashes: 1/2/3-byte', 'wrong mixed label'); + }); + + await step('untrusted evidence cannot create markup or attributes', async () => { + await render([{ + ...baseMessage, + observedPathHashSizes: [''], + }]); + assert(await page.locator('.ch-path-hash-badge').count() === 0, 'malformed evidence rendered a badge'); + assert(await page.locator('#chMessages img').count() === 0, 'evidence injected an image'); + assert(await page.evaluate(() => !window.__hashSizePwned), 'injected handler executed'); + }); + + await step('locally decrypted WS shape preserves packet evidence', async () => { + await render([]); + await page.evaluate(() => { + window._channelsProcessWSBatchForTest([{ + type: 'packet', + data: { + hash: 'client-decrypted-hash', + packet: { observed_path_hash_sizes: [3] }, + decoded: { + header: { payloadTypeName: 'GRP_TXT' }, + payload: { + channel: '#hash-evidence', + sender: 'LocalDecrypt', + text: 'decrypted in browser', + decryptedLocally: true, + }, + }, + }, + }], []); + }); + const state = await page.evaluate(() => window._channelsGetStateForTest()); + const message = state.messages.find((item) => item.packetHash === 'client-decrypted-hash'); + assert(message, 'client-decrypted WS message was not appended'); + assert(JSON.stringify(message.observedPathHashSizes) === '[3]', 'WS evidence was not normalized'); + assert(await page.locator('.ch-path-hash-badge').textContent() === 'Observed path hash: 3-byte', 'WS badge missing'); + }); + + await step('duplicate WS observations union their known sizes', async () => { + await render([]); + await page.evaluate(() => { + const make = (size, observer) => ({ + type: 'message', + data: { + hash: 'ws-union-hash', + observer: observer, + observed_path_hash_sizes: [size], + decoded: { payload: { channel: '#hash-evidence', sender: 'UnionNode', text: 'same' } }, + }, + }); + window._channelsProcessWSBatchForTest([make(1, 'one')], []); + window._channelsProcessWSBatchForTest([make(2, 'two')], []); + }); + const sizes = await page.evaluate(() => { + const item = window._channelsGetStateForTest().messages.find((m) => m.packetHash === 'ws-union-hash'); + return item && item.observedPathHashSizes; + }); + assert(JSON.stringify(sizes) === '[1,2]', 'duplicate observations did not union: ' + JSON.stringify(sizes)); + assert(await page.locator('.ch-path-hash-badge').textContent() === 'Mixed path hashes: 1/2-byte', 'mixed WS badge missing'); + }); + + await step('delayed REST refresh cannot overwrite richer WS evidence', async () => { + await render([{ + ...baseMessage, + packetHash: 'refresh-union-hash', + observedPathHashSizes: [2, 3], + _fromWS: true, + _wsAt: Date.now(), + }]); + const result = await page.evaluate(async () => { + const originalApi = window.api; + window.api = async function (path) { + if (path.indexOf('/channels/') === 0) { + return { messages: [{ + sender: 'EvidenceNode', + text: 'hello', + timestamp: '2026-09-28T08:00:00Z', + packetHash: 'refresh-union-hash', + observers: [], + repeats: 1, + observedPathHashSizes: [1], + }] }; + } + return originalApi.apply(this, arguments); + }; + try { + await window._channelsRefreshMessagesForTest({ forceNoCache: true }); + const item = window._channelsGetStateForTest().messages.find((m) => m.packetHash === 'refresh-union-hash'); + return item && item.observedPathHashSizes; + } finally { + window.api = originalApi; + } + }); + assert(JSON.stringify(result) === '[1,2,3]', 'REST refresh lost WS evidence: ' + JSON.stringify(result)); + assert(await page.locator('.ch-path-hash-badge').textContent() === 'Mixed path hashes: 1/2/3-byte', 'refresh badge missing'); + }); + + await step('badge remains visible without horizontal overflow at 375px', async () => { + await page.setViewportSize({ width: 375, height: 740 }); + await render([{ ...baseMessage, observedPathHashSizes: [1, 2, 3] }]); + const metrics = await page.locator('.ch-path-hash-badge').evaluate((el) => { + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return { + left: rect.left, + right: rect.right, + visible: rect.width > 0 && rect.height > 0, + viewport: document.documentElement.clientWidth, + docWidth: document.documentElement.scrollWidth, + display: style.display, + maxWidth: style.maxWidth, + }; + }); + assert(metrics.visible, 'badge is not visible on mobile'); + assert(metrics.display === 'inline-flex' && metrics.maxWidth === '100%', + 'compact/mobile badge CSS is not applied: ' + JSON.stringify(metrics)); + assert(metrics.left >= 0 && metrics.right <= metrics.viewport + 0.5, + 'badge clips outside viewport: ' + JSON.stringify(metrics)); + assert(metrics.docWidth <= metrics.viewport + 0.5, + 'badge introduces horizontal page overflow: ' + JSON.stringify(metrics)); + }); + + await browser.close(); + console.log('\n=== observed path-hash-size browser: ' + passed + ' passed, ' + failed + ' failed ===\n'); + process.exit(failed === 0 ? 0 : 1); +})().catch((e) => { + console.error(e && e.stack ? e.stack : e); + process.exit(1); +}); diff --git a/test-channels-observed-path-hash-size.js b/test-channels-observed-path-hash-size.js new file mode 100644 index 000000000..7d759ec71 --- /dev/null +++ b/test-channels-observed-path-hash-size.js @@ -0,0 +1,278 @@ +/** + * Observed path-hash-size frontend contract. + * + * Exercises the real helpers exported by public/channels.js. The server may + * expose snake_case evidence on packet/WebSocket shapes and camelCase evidence + * on channel-message REST rows. The browser keeps one sorted, unique union per + * packet hash so a delayed REST response cannot erase richer live evidence. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +const noop = () => {}; +const fakeEl = { + addEventListener: noop, + querySelector: () => fakeEl, + querySelectorAll: () => [], + classList: { add: noop, remove: noop, toggle: noop, contains: () => false }, + appendChild: noop, + removeChild: noop, + setAttribute: noop, + getAttribute: () => null, + textContent: '', + innerHTML: '', + style: {}, + dataset: {}, +}; +const doc = { + readyState: 'complete', + createElement: () => ({ ...fakeEl }), + head: fakeEl, + body: fakeEl, + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener: noop, +}; +const win = { addEventListener: noop }; +const ctx = { + window: win, + document: doc, + console, + Date, + Math, + JSON, + Set, + Map, + Array, + Object, + Promise, + Response: function () {}, + Error, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + history: { replaceState: noop, pushState: noop }, + location: { hash: '', href: '', pathname: '/' }, + navigator: { userAgent: 'node' }, + RegionFilter: { getRegionParam: () => '' }, + api: () => Promise.resolve({ messages: [] }), + CLIENT_TTL: {}, + ChannelDecrypt: undefined, + truncate: (s) => s, + formatHashHex: (h) => String(h), + channelDisplayName: (c) => c && c.name, + escapeHtml: (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''), + getSenderColor: () => 'var(--accent)', + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + btoa: (s) => Buffer.from(s, 'binary').toString('base64'), + registerPage: noop, +}; +vm.createContext(ctx); +// Expose the cache fetch helper only inside this VM so the regression can +// exercise the real delta/early-return path without adding a production API. +const channelsSource = fs.readFileSync('public/channels.js', 'utf8').replace( + 'window._channelsRenderMessagesForTest = renderMessages;', + 'window._channelsFetchAndDecryptChannelForTest = fetchAndDecryptChannel;\n' + + ' window._channelsRenderMessagesForTest = renderMessages;'); +vm.runInContext(channelsSource, ctx); + +const normalize = ctx.window._channelsNormalizeObservedPathHashSizesForTest; +const union = ctx.window._channelsUnionObservedPathHashSizesForTest; +const badge = ctx.window._channelsRenderObservedPathHashBadgeForTest; +const merge = ctx.window._channelsMergeWsAppendedIntoRestForTest; +const dedup = ctx.window._channelsDeduplicateAndMergeForTest; +const fetchAndDecrypt = ctx.window._channelsFetchAndDecryptChannelForTest; + +for (const [name, fn] of Object.entries({ normalize, union, badge, merge, dedup, fetchAndDecrypt })) { + if (typeof fn !== 'function') { + console.error('FATAL: missing channels.js test export: ' + name); + process.exit(2); + } +} + +let passed = 0; +let failed = 0; +const tests = []; +function test(name, fn) { + tests.push({ name, fn }); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +console.log('\n=== channels observed path-hash-size evidence ==='); + +test('normalizes both API spellings into sorted unique 1/2/3 evidence', () => { + assert.deepStrictEqual( + plain(normalize({ + observedPathHashSizes: [3, 1, 2, 2], + observed_path_hash_sizes: [1, 3], + })), + [1, 2, 3]); +}); + +test('rejects unknown, empty and injection-shaped values', () => { + assert.deepStrictEqual(plain(normalize(null)), []); + assert.deepStrictEqual(plain(normalize({ observedPathHashSizes: [] })), []); + assert.deepStrictEqual( + plain(normalize({ observed_path_hash_sizes: [0, 4, null, true, '2