Add geohash bulletin board: persistent signed notices over mesh sync

New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.

Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.

UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 20:11:17 +02:00
co-authored by Claude Fable 5
parent 7341696280
commit 81168adad1
19 changed files with 1958 additions and 4 deletions
@@ -0,0 +1,211 @@
//
// BoardPacketsTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct BoardPacketsTests {
private let authorKey = Curve25519.Signing.PrivateKey()
private func makeSignedPost(
geohash: String = "9q8yy",
content: String = "water point at the north gate",
nickname: String = "ranger",
createdAt: UInt64 = 1_700_000_000_000,
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000,
flags: UInt8 = 0,
signWith key: Curve25519.Signing.PrivateKey? = nil,
claimKey: Data? = nil
) throws -> BoardPostPacket {
let signer = key ?? authorKey
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
let expiresAt = createdAt + lifetimeMs
let signingBytes = BoardPostPacket.signingBytes(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: publicKey,
authorNickname: nickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags
)
let signature = try signer.signature(for: signingBytes)
return BoardPostPacket(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: publicKey,
authorNickname: nickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags,
signature: signature
)
}
private func makeSignedTombstone(
postID: Data,
deletedAt: UInt64 = 1_700_000_100_000,
signWith key: Curve25519.Signing.PrivateKey? = nil,
claimKey: Data? = nil
) throws -> BoardTombstonePacket {
let signer = key ?? authorKey
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt))
return BoardTombstonePacket(
postID: postID,
authorSigningKey: publicKey,
deletedAt: deletedAt,
signature: signature
)
}
// MARK: - Round trips
@Test func postRoundTrip() throws {
let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
let encoded = BoardWire.post(post).encode()
let decoded = try #require(BoardWire.decode(from: encoded))
#expect(decoded == .post(post))
#expect(decoded.verifySignature())
guard case .post(let roundTripped) = decoded else {
Issue.record("expected a post")
return
}
#expect(roundTripped.isUrgent)
#expect(roundTripped.geohash == "9q8yy")
}
@Test func meshLocalPostRoundTrip() throws {
let post = try makeSignedPost(geohash: "")
let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode()))
#expect(decoded == .post(post))
#expect(decoded.verifySignature())
}
@Test func tombstoneRoundTrip() throws {
let post = try makeSignedPost()
let tombstone = try makeSignedTombstone(postID: post.postID)
let encoded = BoardWire.tombstone(tombstone).encode()
let decoded = try #require(BoardWire.decode(from: encoded))
#expect(decoded == .tombstone(tombstone))
#expect(decoded.verifySignature())
}
// MARK: - Signature verification
@Test func forgedPostSignatureFailsVerification() throws {
// Signed by an attacker's key but claiming the victim's key as author.
let attacker = Curve25519.Signing.PrivateKey()
let victim = Curve25519.Signing.PrivateKey()
let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation)
let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode()))
#expect(!decoded.verifySignature())
}
@Test func tamperedContentFailsVerification() throws {
let post = try makeSignedPost(content: "meet at noon")
let tampered = BoardPostPacket(
postID: post.postID,
geohash: post.geohash,
content: "meet at midnight",
authorSigningKey: post.authorSigningKey,
authorNickname: post.authorNickname,
createdAt: post.createdAt,
expiresAt: post.expiresAt,
flags: post.flags,
signature: post.signature
)
let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode()))
#expect(!decoded.verifySignature())
}
@Test func forgedTombstoneSignatureFailsVerification() throws {
let post = try makeSignedPost()
let attacker = Curve25519.Signing.PrivateKey()
let forged = try makeSignedTombstone(
postID: post.postID,
signWith: attacker,
claimKey: post.authorSigningKey
)
let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode()))
#expect(!decoded.verifySignature())
}
// MARK: - Decode validation
@Test func rejectsExpiryBeyondSevenDays() throws {
let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1)
#expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil)
let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs)
#expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil)
}
@Test func rejectsExpiryBeforeCreation() throws {
let post = try makeSignedPost()
let inverted = BoardPostPacket(
postID: post.postID,
geohash: post.geohash,
content: post.content,
authorSigningKey: post.authorSigningKey,
authorNickname: post.authorNickname,
createdAt: post.expiresAt,
expiresAt: post.createdAt,
flags: post.flags,
signature: post.signature
)
#expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil)
}
@Test func rejectsOversizedContent() throws {
let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1))
#expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil)
let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes))
#expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil)
}
@Test func rejectsInvalidGeohashCharacters() throws {
let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32
#expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil)
}
@Test func toleratesUnknownTLVs() throws {
let post = try makeSignedPost()
var encoded = BoardWire.post(post).encode()
// Append an unknown TLV; decoders must skip it.
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
let decoded = try #require(BoardWire.decode(from: encoded))
#expect(decoded == .post(post))
#expect(decoded.verifySignature())
}
@Test func rejectsTruncatedPayload() throws {
let post = try makeSignedPost()
let encoded = BoardWire.post(post).encode()
#expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil)
}
// MARK: - Urgent flag peek
@Test func urgentFlagPeekMatchesFullDecode() throws {
let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
let calm = try makeSignedPost()
let tombstone = try makeSignedTombstone(postID: calm.postID)
#expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode()))
#expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode()))
#expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode()))
#expect(!BoardWire.urgentFlag(in: Data()))
}
}
+277
View File
@@ -0,0 +1,277 @@
//
// BoardStoreTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct BoardStoreTests {
private final class MutableClock: @unchecked Sendable {
var now: Date
init(now: Date) { self.now = now }
}
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore {
BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now })
}
private func tempFileURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("board-store-\(UUID().uuidString).json")
}
private func makePost(
author: Curve25519.Signing.PrivateKey,
geohash: String = "9q8yy",
content: String = "note",
createdAt: UInt64,
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000
) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) {
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
let key = author.publicKey.rawRepresentation
let expiresAt = createdAt + lifetimeMs
let signingBytes = BoardPostPacket.signingBytes(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: key,
authorNickname: "tester",
createdAt: createdAt,
expiresAt: expiresAt,
flags: 0
)
let post = BoardPostPacket(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: key,
authorNickname: "tester",
createdAt: createdAt,
expiresAt: expiresAt,
flags: 0,
signature: try author.signature(for: signingBytes)
)
let wire = BoardWire.post(post)
return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post)
}
private func makeTombstone(
for post: BoardPostPacket,
author: Curve25519.Signing.PrivateKey,
deletedAt: UInt64,
claimKey: Data? = nil
) throws -> (wire: BoardWire, packet: BitchatPacket) {
let tombstone = BoardTombstonePacket(
postID: post.postID,
authorSigningKey: claimKey ?? author.publicKey.rawRepresentation,
deletedAt: deletedAt,
signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt))
)
let wire = BoardWire.tombstone(tombstone)
return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt))
}
private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket {
BitchatPacket(
type: MessageType.boardPost.rawValue,
senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }),
recipientID: nil,
timestamp: timestamp,
payload: payload,
signature: nil,
ttl: 7
)
}
// MARK: - Ingest basics
@Test func ingestStoresAndDeduplicates() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs)
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
#expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate)
#expect(store.posts(forGeohash: "9q8yy").count == 1)
#expect(store.posts(forGeohash: "").isEmpty)
#expect(store.syncCandidates().count == 1)
}
@Test func rejectsAlreadyExpiredPost() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000)
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
}
// MARK: - Caps and eviction
@Test func perAuthorCapEvictsOldest() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
var oldestID: Data?
for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) {
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
if index == 0 { oldestID = entry.post.postID }
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
}
let posts = store.posts(forGeohash: "9q8yy")
#expect(posts.count == BoardStore.Limits.maxPostsPerAuthor)
#expect(!posts.contains { $0.postID == oldestID })
}
@Test func globalCapEvictsOldest() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
var oldestID: Data?
var author = Curve25519.Signing.PrivateKey()
for index in 0..<(BoardStore.Limits.maxPosts + 1) {
if index % BoardStore.Limits.maxPostsPerAuthor == 0 {
author = Curve25519.Signing.PrivateKey()
}
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
if index == 0 { oldestID = entry.post.postID }
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
}
let posts = store.posts(forGeohash: "9q8yy")
#expect(posts.count == BoardStore.Limits.maxPosts)
#expect(!posts.contains { $0.postID == oldestID })
}
// MARK: - Expiry sweep
@Test func expiredPostsAreSwept() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000)
let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000)
store.ingest(shortLived.wire, packet: shortLived.packet)
store.ingest(longLived.wire, packet: longLived.packet)
#expect(store.posts(forGeohash: "9q8yy").count == 2)
clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later
let remaining = store.posts(forGeohash: "9q8yy")
#expect(remaining.count == 1)
#expect(remaining.first?.postID == longLived.post.postID)
#expect(store.syncCandidates().count == 1)
}
// MARK: - Tombstones
@Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000)
store.ingest(entry.wire, packet: entry.packet)
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
// Post is gone, tombstone still syncs so the delete propagates.
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
#expect(store.syncCandidates().count == 1)
// Replayed copy of the deleted post is refused.
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
// After the post's original expiry the tombstone is dropped too.
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
#expect(store.syncCandidates().isEmpty)
}
@Test func tombstoneFromWrongKeyIsRejected() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let attacker = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs)
store.ingest(entry.wire, packet: entry.packet)
// Attacker signs with their own key (self-consistent wire, so it
// passes signature verification) but targets the victim's post.
let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000)
#expect(store.ingest(forged.wire, packet: forged.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").count == 1)
}
@Test func tombstoneArrivingBeforePostSuppressesIt() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs)
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
}
// MARK: - Persistence and wipe
@Test func persistsAcrossRestart() throws {
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let clock = MutableClock(now: baseDate)
let author = Curve25519.Signing.PrivateKey()
let first = makeStore(clock: clock, fileURL: fileURL)
let entry = try makePost(author: author, createdAt: baseMs)
let deleted = try makePost(author: author, createdAt: baseMs + 1)
first.ingest(entry.wire, packet: entry.packet)
first.ingest(deleted.wire, packet: deleted.packet)
let tombstone = try makeTombstone(for: deleted.post, author: author, deletedAt: baseMs + 1000)
first.ingest(tombstone.wire, packet: tombstone.packet)
let second = makeStore(clock: clock, fileURL: fileURL)
let restored = second.posts(forGeohash: "9q8yy")
#expect(restored.count == 1)
#expect(restored.first?.postID == entry.post.postID)
// Post + tombstone both restored into sync.
#expect(second.syncCandidates().count == 2)
// Restart after expiry drops everything.
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
let third = makeStore(clock: clock, fileURL: fileURL)
#expect(third.posts(forGeohash: "9q8yy").isEmpty)
#expect(third.syncCandidates().isEmpty)
}
@Test func wipeClearsMemoryAndDisk() throws {
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let clock = MutableClock(now: baseDate)
let author = Curve25519.Signing.PrivateKey()
let store = makeStore(clock: clock, fileURL: fileURL)
let entry = try makePost(author: author, createdAt: baseMs)
store.ingest(entry.wire, packet: entry.packet)
#expect(FileManager.default.fileExists(atPath: fileURL.path))
store.wipe()
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
#expect(store.syncCandidates().isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
}
@@ -0,0 +1,130 @@
//
// GossipSyncBoardTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
/// Board posts ride gossip sync through a provider that queries the board
/// store, so retention (expiry, tombstones, caps) has a single owner.
struct GossipSyncBoardTests {
private let myPeerID = PeerID(str: "0102030405060708")
private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
BitchatPacket(
type: MessageType.boardPost.rawValue,
senderID: try #require(Data(hexString: "aabbccddeeff0011")),
recipientID: nil,
timestamp: timestamp,
payload: Data([0x42]),
signature: nil,
ttl: 7
)
}
private func quietConfig() -> GossipSyncManager.Config {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
return config
}
@Test func boardRequestIsServedFromProvider() async throws {
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
let delegate = RecordingBoardDelegate()
manager.delegate = delegate
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
manager.boardPacketsProvider = { return [boardPacket] }
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.boardPost.rawValue)
#expect(sent.isRSR)
}
@Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
let delegate = RecordingBoardDelegate()
manager.delegate = delegate
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
manager.boardPacketsProvider = { return [boardPacket] }
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
// Follow with a board request; only its response should arrive, which
// also proves the first request produced nothing.
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
#expect(delegate.packets.count == 1)
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
}
@Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
var config = quietConfig()
config.boardSyncIntervalSeconds = 1
// Without a provider the board schedule stays silent.
let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
let unwiredDelegate = RecordingBoardDelegate()
unwired.delegate = unwiredDelegate
unwired._performMaintenanceSynchronously(now: Date())
#expect(unwiredDelegate.packets.isEmpty)
// With a provider, maintenance sends a board-typed request.
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
let delegate = RecordingBoardDelegate()
manager.delegate = delegate
manager.boardPacketsProvider = { return [] }
manager._performMaintenanceSynchronously(now: Date())
#expect(delegate.packets.count == 1)
let payload = try #require(delegate.packets.first?.payload)
let request = try #require(RequestSyncPacket.decode(from: payload))
#expect(request.types == .board)
}
}
private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
private let lock = NSLock()
private var _packets: [BitchatPacket] = []
var packets: [BitchatPacket] {
lock.lock()
defer { lock.unlock() }
return _packets
}
func sendPacket(_ packet: BitchatPacket) {
lock.lock()
_packets.append(packet)
lock.unlock()
}
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
lock.lock()
_packets.append(packet)
lock.unlock()
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
packet
}
func getConnectedPeers() -> [PeerID] {
[]
}
}
@@ -0,0 +1,78 @@
//
// SyncTypeFlagsBoardTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
/// The board sync flag is the first bit outside the original single byte of
/// type flags. These tests pin down the wire compatibility contract: the
/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
/// since type-aware sync, so widening to two bytes must decode everywhere
/// and unknown bits must be ignored, not rejected.
struct SyncTypeFlagsBoardTests {
@Test func boardFlagEncodesIntoSecondByte() throws {
let data = try #require(SyncTypeFlags.board.toData())
// Little-endian: low byte first, board bit (bit 8) in byte 2.
#expect(data == Data([0x00, 0x01]))
}
@Test func boardFlagRoundTrips() throws {
let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
let data = try #require(flags.toData())
let decoded = try #require(SyncTypeFlags.decode(data))
#expect(decoded.contains(.message))
#expect(decoded.contains(.boardPost))
#expect(!decoded.contains(.fragment))
#expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
}
/// An old decoder is modeled by bits it has no mapping for: the shared
/// decode path accepts the bytes and simply maps unknown bits to no
/// message type, so a board-only request reads as "nothing I can serve".
@Test func unknownBitsDecodeToNoTypes() throws {
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
// must decode without error and yield no known types.
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
#expect(decoded.toMessageTypes().isEmpty)
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
#expect(!decoded.contains(type))
}
}
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
// Known low-byte flags survive alongside unknown high bits.
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
#expect(decoded.contains(.announce))
#expect(decoded.contains(.message))
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
}
@Test func requestSyncPacketRoundTripsBoardFlag() throws {
let request = RequestSyncPacket(
p: 4,
m: 128,
data: Data([0xAB, 0xCD]),
types: SyncTypeFlags(messageTypes: [.boardPost])
)
let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
let types = try #require(decoded.types)
#expect(types.contains(.boardPost))
#expect(!types.contains(.message))
}
@Test func singleByteLegacyEncodingStillDecodes() throws {
// Requests from old clients keep the one-byte bitfield.
let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
#expect(decoded.contains(.announce))
#expect(decoded.contains(.message))
#expect(!decoded.contains(.boardPost))
}
}