From 8eeb9cce99173277b18a3afe3daca58e49198f48 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 29 Jul 2026 19:23:12 +0200 Subject: [PATCH 1/3] feat: add Sonar sticker reference wire codec (PR A of #1517 split) Pure-Swift codec for the sonar-sticker-pack-v1 wire format (\x1Fsticker\x1F\x1F\x1F), byte-identical to sonar-ffi, plus the wire spec doc and the reserved-not-advertised capability bit 13 note. No behavior change: nothing parses or routes sticker content yet (PRs B/C build on this). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Protocols/PeerCapabilities+Local.swift | 9 ++ bitchat/Protocols/StickerRefCodec.swift | 93 +++++++++++ .../Protocols/StickerRefCodecTests.swift | 148 ++++++++++++++++++ docs/SONAR-STICKERS.md | 147 +++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 bitchat/Protocols/StickerRefCodec.swift create mode 100644 bitchatTests/Protocols/StickerRefCodecTests.swift create mode 100644 docs/SONAR-STICKERS.md diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index d48891d7..cb4c65ba 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -1,6 +1,15 @@ import BitFoundation extension PeerCapabilities { + // NOTE: a `.stickers` bit is RESERVED at bit 13 but intentionally not + // declared or advertised in v1 — sticker refs ride as ordinary message + // content and need no capability negotiation (old clients render the + // literal text; see `docs/SONAR-STICKERS.md`). Bit 11 is claimed by + // #1107 (double ratchet) and bit 12 by #1438 (spray recovery); bit 10 + // stays reserved (`nonDestructiveNoiseReplacement`). Declare the bit + // here — or in BitFoundation on the next bump — when inline-BLE sticker + // delivery (Approach B) ships. + /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. static let localSupported: PeerCapabilities = [ diff --git a/bitchat/Protocols/StickerRefCodec.swift b/bitchat/Protocols/StickerRefCodec.swift new file mode 100644 index 00000000..96402c50 --- /dev/null +++ b/bitchat/Protocols/StickerRefCodec.swift @@ -0,0 +1,93 @@ +import Foundation + +/// A reference to a single sticker inside a Sonar sticker pack, carried as +/// ordinary message content. +/// +/// Interop-identical to sonar-ffi's `mesh_sticker_content` / +/// `mesh_parse_sticker_content`: a ref encodes to +/// `␟sticker␟` +/// where `␟` is ASCII Unit Separator (0x1F). The full upstream pack spec +/// lives at https://sonarprivacy.xyz/docs#SONAR-STICKERS. +struct StickerRef: Equatable, Sendable { + /// Nostr addressable coordinate of the pack: `30031:<64 lowercase hex pubkey>:`. + let packCoordinate: String + /// Sticker shortcode within the pack: 1-64 chars of `[A-Za-z0-9_]`. + let shortcode: String + /// Lowercase hex SHA-256 of the decrypted sticker plaintext (64 chars). + let plaintextSha256: String + + /// Validated factory. Returns nil unless every field matches the wire + /// shape exactly; encoding an invalid ref is not representable. + init?(packCoordinate: String, shortcode: String, plaintextSha256: String) { + guard StickerRef.isValidCoordinate(packCoordinate), + StickerRef.isValidShortcode(shortcode), + StickerRef.isValidSha256(plaintextSha256) + else { return nil } + self.packCoordinate = packCoordinate + self.shortcode = shortcode + self.plaintextSha256 = plaintextSha256 + } + + /// The wire-format message content for this ref. + var content: String { StickerRefCodec.encode(self) } + + // MARK: - Validation helpers (reused by the pack service) + + /// `30031:<64 lowercase hex>:` where identifier is 1-80 + /// chars of `[A-Za-z0-9._-]`. + static func isValidCoordinate(_ value: String) -> Bool { + let parts = value.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 3, parts[0] == "30031" else { return false } + guard isLowerHex(parts[1], count: 64) else { return false } + let identifier = parts[2] + guard (1...80).contains(identifier.count) else { return false } + return identifier.allSatisfy { byte in + byte.isASCII && (byte.isLetter || byte.isNumber || byte == "." || byte == "_" || byte == "-") + } + } + + /// 1-64 chars of `[A-Za-z0-9_]`. + static func isValidShortcode(_ value: String) -> Bool { + guard (1...64).contains(value.count) else { return false } + return value.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "_") } + } + + /// Exactly 64 lowercase hex chars. + static func isValidSha256(_ value: String) -> Bool { + isLowerHex(Substring(value), count: 64) + } + + private static func isLowerHex(_ value: Substring, count: Int) -> Bool { + guard value.count == count else { return false } + return value.allSatisfy { ($0 >= "0" && $0 <= "9") || ($0 >= "a" && $0 <= "f") } + } +} + +/// Pure-Swift codec for the Sonar sticker-ref wire format, byte-identical to +/// sonar-ffi's `mesh_sticker_content` / `mesh_parse_sticker_content`. +enum StickerRefCodec { + /// ASCII Unit Separator; field delimiter and leading sentinel. + static let separator: Character = "\u{1F}" + + /// `\u{1F}sticker\u{1F}\u{1F}\u{1F}` + static func encode(_ ref: StickerRef) -> String { + "\u{1F}sticker\u{1F}\(ref.packCoordinate)\u{1F}\(ref.shortcode)\u{1F}\(ref.plaintextSha256)" + } + + /// Parses message content into a ref. Returns nil for anything that is + /// not exactly five `\u{1F}`-separated fields with an empty leading + /// field, the literal tag `sticker`, and three validated fields. + /// + /// This parses attacker-controlled mesh content: it must never crash and + /// must never accept a malformed ref, so old clients render the literal + /// text instead of a bogus sticker. + static func parse(_ content: String) -> StickerRef? { + let parts = content.split(separator: separator, maxSplits: 4, omittingEmptySubsequences: false) + guard parts.count == 5, parts[0].isEmpty, parts[1] == "sticker" else { return nil } + return StickerRef( + packCoordinate: String(parts[2]), + shortcode: String(parts[3]), + plaintextSha256: String(parts[4]) + ) + } +} diff --git a/bitchatTests/Protocols/StickerRefCodecTests.swift b/bitchatTests/Protocols/StickerRefCodecTests.swift new file mode 100644 index 00000000..03c8c418 --- /dev/null +++ b/bitchatTests/Protocols/StickerRefCodecTests.swift @@ -0,0 +1,148 @@ +// +// StickerRefCodecTests.swift +// bitchatTests +// +// Tests for the Sonar sticker-ref wire codec: round-trip, sonar-ffi parity +// vectors, strict shape rejection, and adversarial (separator-only / huge / +// emoji) input. The codec parses attacker-controlled mesh content, so +// "never crash" matters as much as "parse correctly". +// + +import Foundation +import Testing +@testable import bitchat + +struct StickerRefCodecTests { + + // MARK: - Builders + + private let pubkey = String(repeating: "ab", count: 32) // 64 lowercase hex + private let sha256 = String(repeating: "deadbeef", count: 8) // 64 lowercase hex + + private func makeRef( + coordinate: String? = nil, + shortcode: String = "wave", + hash: String? = nil + ) -> StickerRef? { + StickerRef( + packCoordinate: coordinate ?? "30031:\(pubkey):my-pack", + shortcode: shortcode, + plaintextSha256: hash ?? sha256 + ) + } + + // MARK: - Round trip + + @Test func encodeParseRoundTrip() { + let ref = makeRef() + #expect(ref != nil) + let parsed = StickerRefCodec.parse(StickerRefCodec.encode(ref!)) + #expect(parsed == ref) + #expect(ref!.content == StickerRefCodec.encode(ref!)) + } + + // MARK: - sonar-ffi parity vectors + + @Test func encodeMatchesSonarFfiBytes() { + // mesh_sticker_content output must be byte-identical. + let ref = makeRef()! + let expected = "\u{1F}sticker\u{1F}30031:\(pubkey):my-pack\u{1F}wave\u{1F}\(sha256)" + #expect(StickerRefCodec.encode(ref) == expected) + #expect(StickerRefCodec.encode(ref).hasPrefix("\u{1F}sticker\u{1F}")) + } + + @Test func parseAcceptsSonarFfiEncodedContent() { + // Hand-built exactly as mesh_parse_sticker_content would see it. + let wire = "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)" + let ref = StickerRefCodec.parse(wire) + #expect(ref == makeRef(coordinate: "30031:\(pubkey):pack")) + } + + // MARK: - Not a sticker at all + + @Test func rejectsNonStickerContent() { + #expect(StickerRefCodec.parse("hello world") == nil) + #expect(StickerRefCodec.parse("") == nil) + #expect(StickerRefCodec.parse("sticker:fake") == nil) + // "sticker" tag without the leading Unit Separator sentinel. + #expect(StickerRefCodec.parse("sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)") == nil) + // Right tag, wrong sentinel position. + #expect(StickerRefCodec.parse("x\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)") == nil) + } + + // MARK: - Coordinate validation + + @Test func rejectsBadCoordinates() { + func ref(with coordinate: String) -> StickerRef? { makeRef(coordinate: coordinate) } + + #expect(ref(with: "30032:\(pubkey):pack") == nil) // wrong kind + #expect(ref(with: "30031:\(pubkey.uppercased()):pack") == nil) // uppercase hex + #expect(ref(with: "30031:\(String(pubkey.dropLast())):pack") == nil) // short pubkey + #expect(ref(with: "30031:\(pubkey)00:pack") == nil) // long pubkey + #expect(ref(with: "30031:\(pubkey):bad id") == nil) // space in identifier + #expect(ref(with: "30031:\(pubkey):bad/id") == nil) // slash in identifier + #expect(ref(with: "30031:\(pubkey):") == nil) // empty identifier + #expect(ref(with: "30031:\(pubkey):\(String(repeating: "a", count: 81))") == nil) // 81-char identifier + #expect(ref(with: "30031:\(pubkey)") == nil) // missing identifier field + #expect(ref(with: ":\(pubkey):pack") == nil) // empty kind + } + + @Test func acceptsCoordinateBoundaryIdentifiers() { + #expect(makeRef(coordinate: "30031:\(pubkey):a") != nil) + #expect(makeRef(coordinate: "30031:\(pubkey):\(String(repeating: "a", count: 80))") != nil) + #expect(makeRef(coordinate: "30031:\(pubkey):A-Z_a.z-09") != nil) + } + + // MARK: - Shortcode validation + + @Test func rejectsBadShortcodes() { + #expect(makeRef(shortcode: "") == nil) + #expect(makeRef(shortcode: String(repeating: "w", count: 65)) == nil) + #expect(makeRef(shortcode: "wavé") == nil) // non-ASCII + #expect(makeRef(shortcode: "so-wave") == nil) // dash not allowed + #expect(makeRef(shortcode: "so wave") == nil) + } + + @Test func acceptsShortcodeBoundaries() { + #expect(makeRef(shortcode: "a") != nil) + #expect(makeRef(shortcode: String(repeating: "w", count: 64)) != nil) + #expect(makeRef(shortcode: "Wave_09") != nil) + } + + // MARK: - SHA-256 validation + + @Test func rejectsBadSha256() { + #expect(makeRef(hash: String(repeating: "a", count: 63)) == nil) + #expect(makeRef(hash: String(repeating: "a", count: 65)) == nil) + #expect(makeRef(hash: sha256.uppercased()) == nil) + #expect(makeRef(hash: String(repeating: "g", count: 64)) == nil) // non-hex + } + + // MARK: - Field count / framing + + @Test func rejectsWrongFieldCounts() { + let base = "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)" + #expect(StickerRefCodec.parse(base + "\u{1F}extra") == nil) // trailing field + #expect(StickerRefCodec.parse(base + "\u{1F}\u{1F}\u{1F}") == nil) // >4 splits + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave") == nil) // missing hash + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}\u{1F}\(sha256)") == nil) // empty shortcode field + } + + // MARK: - Never-crash fuzz-ish + + @Test func pathologicalInputsNeverCrashAndNeverParse() { + let cases = [ + String(repeating: "\u{1F}", count: 5), // all separators + String(repeating: "\u{1F}", count: 4096), + "\u{1F}sticker" + String(repeating: "\u{1F}", count: 100), + String(repeating: "a", count: 100_000), // very long + "\u{1F}sticker\u{1F}🌊🌊🌊\u{1F}🌊\u{1F}🌊", // emoji fields + "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)\u{1F}", + "\u{1F}sticker\u{1F}\u{1F}\u{1F}\u{1F}", + "sticker\u{1F}\u{1F}\u{1F}\u{1F}\u{1F}", + ] + for content in cases { + #expect(StickerRefCodec.parse(content) == nil) + } + } +} diff --git a/docs/SONAR-STICKERS.md b/docs/SONAR-STICKERS.md new file mode 100644 index 00000000..2e842b3b --- /dev/null +++ b/docs/SONAR-STICKERS.md @@ -0,0 +1,147 @@ +# Sonar Stickers Specification + +## Overview + +Sonar stickers let peers send image stickers from shared, content-addressed +sticker packs over bitchat. A sticker is **not** sent as image bytes on the +mesh; instead the sender emits a small *sticker reference* as ordinary +message content, and receiving clients resolve the reference to an image via +Nostr + Blossom. This keeps the BLE mesh payload tiny while allowing +arbitrarily large sticker art. + +The upstream pack specification (pack format, Blossom publication, signing) +lives at **https://sonarprivacy.xyz/docs#SONAR-STICKERS**. This document +specifies only the bitchat wire format and client behavior. The reference +implementation is byte-identical to sonar-ffi's `mesh_sticker_content` / +`mesh_parse_sticker_content`. + +## Wire Format + +### Content Prefix + +A sticker reference is encoded as message **content** (UTF-8 text) with the +following exact layout: + +``` +␟sticker␟ +``` + +where `␟` is ASCII **Unit Separator (0x1F)**. In Swift: + +```swift +"\u{1F}sticker\u{1F}\(coordinate)\u{1F}\(shortcode)\u{1F}\(sha256)" +``` + +Field order and count are fixed: + +| # | Field | Validation | +|---|--------------------|------------| +| 0 | *(empty sentinel)* | MUST be empty (leading `0x1F`) | +| 1 | tag | MUST be the literal ASCII string `sticker` | +| 2 | pack-coordinate | `30031:<64 lowercase hex>:`; identifier is 1–80 chars of `[A-Za-z0-9._-]` | +| 3 | shortcode | 1–64 chars of `[A-Za-z0-9_]` | +| 4 | plaintext-sha256 | exactly 64 lowercase hex chars (SHA-256 of the decrypted sticker plaintext) | + +### Parsing Rules + +Parsers MUST: + +1. Split the content on `0x1F` with **at most 4 splits**, preserving empty + subsequences. +2. Accept only if the split yields **exactly 5 parts**, `parts[0]` is empty, + and `parts[1] == "sticker"`. +3. Validate fields 2–4 against the table above; any violation MUST cause the + whole parse to fail. +4. Treat a failed parse as ordinary text (see *Old-Client Behavior*). +5. Never crash on malformed input: this content is attacker-controlled on + public channels. + +Senders MUST NOT emit a reference whose fields fail validation. The pack +service SHOULD reuse the same validators (`StickerRef.isValidCoordinate`, +`isValidShortcode`, `isValidSha256`) rather than re-implementing them. + +## Where References May Appear + +Sticker references are always carried as **ordinary message content**: + +- **Private DMs** — inside the existing encrypted Noise envelope, exactly + like a text message. The reference (and thus sticker choice) is never + visible to relays or passive observers. +- **Public mesh channel** — as ordinary broadcast message content. +- **Geohash / Nostr channels** — as the content of the usual geohash chat + event (kind 20000), inside the existing encryption for that channel. + +No new message type, TLV, or Nostr kind is introduced for sending stickers. +v1 does not gate sending on capabilities; receiving clients MUST accept +sticker content regardless (old clients render harmless literal text — see +"Old-Client Behavior" below). + +## Capability Bit + +- **`.stickers` = bit 13** (`1 << 13`) — **reserved, NOT advertised in v1.** + +Bit 10 remains reserved (`nonDestructiveNoiseReplacement`, decodable but +unused), bit 11 is claimed by #1107 (double ratchet), and bit 12 by #1438 +(spray recovery); none of these MUST be reused. The stickers bit is +reserved for a future inline-BLE byte delivery mode and is NOT set in v1 +announce packets: references ride as ordinary message content, so there is +nothing to negotiate. When a peer capability becomes meaningful (e.g. +inline delivery), senders SHOULD prefer sending a ref only to peers +advertising the bit and MAY fall back to a short textual description +(`:shortcode:`) otherwise. + +## Pack Resolution + +Resolution is a client-side cache/fetch concern and never touches the mesh: + +1. **Pack lookup (Nostr, kind 30031).** The pack-coordinate is an + addressable Nostr pointer: kind `30031`, author = the 64-hex pubkey, + `d` tag = the identifier. Clients fetch the pack definition event from + relays and extract the sticker list. +2. **Image fetch (Blossom).** Sticker image bytes are fetched over + **HTTPS only** from Blossom servers, **pinned by SHA-256**: the + downloaded bytes MUST hash to the `plaintext-sha256` from the reference + (and to the hash in the pack entry) before caching or rendering. +3. **Media constraints.** Decoders MUST enforce the MIME allowlist + (`image/webp`, `image/png`, `image/apng`, `image/gif`) and dimensions + ≤ 4096×4096. Packs contain ≤ 200 stickers. +4. **Install list (Nostr, kind 10031).** A user's installed packs are + published as a replaceable kind-`10031` event whose `a` tags are + `30031::` pointers, deduplicated in first-seen + order. Clients SHOULD merge relays' versions keeping the newest event. +5. **Fetch consent (privacy).** Resolving an uninstalled pack on behalf of + an inbound message MUST NOT happen automatically at render time: an + on-render fetch is a read beacon — a per-recipient pack URL tells the + sender exactly when the message was viewed, and DM sticker fetches leak + conversation metadata to relays/Blossom hosts. Uninstalled packs render + a placeholder and fetch only on explicit user consent (e.g. a tap). + Packs the user installed resolve automatically. Failed pack lookups + SHOULD be negative-cached with exponential backoff so a missing/dead + pack is not re-fetched on every render (a repeat beacon). + +See https://sonarprivacy.xyz/docs#SONAR-STICKERS for the full pack and +publication spec. + +## Old-Client Behavior + +Clients that predate this spec (or do not advertise `.stickers`) see the +reference as a plain UTF-8 string. Because Unit Separator is a non-printing +control character, the content renders as roughly +`sticker 30031:…:my-pack wave deadbeef…` — ugly but harmless. Old clients +MUST NOT crash on this content, and this spec adds no bytes outside the +existing content field, so no migration is required. + +## Security Rules + +Receivers and pack services MUST: + +- **Verify before caching.** Never cache or render image bytes whose + SHA-256 does not match the expected `plaintext-sha256`. +- **Untrusted-state rendering.** If the currently resolved pack does not + contain the `(shortcode, hash)` pair from a reference, render an + "untrusted / unknown sticker" placeholder — not whatever image the pack + currently has under that shortcode. +- **HTTPS only.** Never fetch or render non-HTTPS URLs, regardless of what + a pack event claims. +- **Never crash on input.** All parsing of references, pack events, and + image metadata is attacker-controlled; treat every field as hostile. From cc63c0c9a78352e722a03c622e4bbbf9c395220a Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Fri, 31 Jul 2026 12:54:12 +0200 Subject: [PATCH 2/3] test: reject truncated and oversized sticker wire refs Addresses review feedback on #1544: pin that wire refs cut at field boundaries or mid-hash, and refs with 64KiB oversized fields, all parse to nil so a bad packet cannot inflate the decoder. --- .../Protocols/StickerRefCodecTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bitchatTests/Protocols/StickerRefCodecTests.swift b/bitchatTests/Protocols/StickerRefCodecTests.swift index 03c8c418..de1668a0 100644 --- a/bitchatTests/Protocols/StickerRefCodecTests.swift +++ b/bitchatTests/Protocols/StickerRefCodecTests.swift @@ -128,6 +128,28 @@ struct StickerRefCodecTests { #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}\u{1F}\(sha256)") == nil) // empty shortcode field } + // MARK: - Truncated / oversized wire input + + @Test func rejectsTruncatedAndOversizedWireRefs() { + let wire = "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)" + + // Truncated at a field boundary, mid-hash, and mid-tag: a cut + // packet must never parse as a valid ref. + for cut in [wire.count - 1, wire.count - 32, wire.count / 2, 9, 1] { + #expect(StickerRefCodec.parse(String(wire.prefix(cut))) == nil) + } + // Valid framing with a hash truncated to 63 chars. + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(String(sha256.dropLast()))") == nil) + + // Oversized fields (64KiB) must be rejected by the length guards + // before any field is trusted, so a bad packet cannot make the + // decoder retain an inflated buffer. + let huge = String(repeating: "a", count: 65_536) + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):\(huge)\u{1F}wave\u{1F}\(sha256)") == nil) + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}\(huge)\u{1F}\(sha256)") == nil) + #expect(StickerRefCodec.parse("\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(huge)") == nil) + } + // MARK: - Never-crash fuzz-ish @Test func pathologicalInputsNeverCrashAndNeverParse() { From 5960b7f343bf98e775ce2f014fd55b959d761884 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Fri, 31 Jul 2026 14:19:50 +0200 Subject: [PATCH 3/3] Address review: split semantics, status note, DM size constraint, vacuous parity test - Parsing Rule 1 now says 'at most 5 parts' and spells out the Swift maxSplits:4 vs Rust splitn(5) off-by-one, which would otherwise make a splitn(4) implementor reject every valid reference. - Overview gains an explicit Status note: nothing parses, routes, fetches, or renders sticker references yet (reviewer ask on #1544). - Document the 255-byte DM content constraint for maximal-length references, pointing at #784 (Codex P2 thread). - parseAcceptsSonarFfiEncodedContent now #requires the parse result: #expect(ref == makeRef(...)) could pass vacuously as nil == nil. Flagged by the multi-model review panel. --- .../Protocols/StickerRefCodecTests.swift | 6 ++++-- docs/SONAR-STICKERS.md | 20 +++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/bitchatTests/Protocols/StickerRefCodecTests.swift b/bitchatTests/Protocols/StickerRefCodecTests.swift index de1668a0..efa75bcd 100644 --- a/bitchatTests/Protocols/StickerRefCodecTests.swift +++ b/bitchatTests/Protocols/StickerRefCodecTests.swift @@ -51,10 +51,12 @@ struct StickerRefCodecTests { #expect(StickerRefCodec.encode(ref).hasPrefix("\u{1F}sticker\u{1F}")) } - @Test func parseAcceptsSonarFfiEncodedContent() { + @Test func parseAcceptsSonarFfiEncodedContent() throws { // Hand-built exactly as mesh_parse_sticker_content would see it. let wire = "\u{1F}sticker\u{1F}30031:\(pubkey):pack\u{1F}wave\u{1F}\(sha256)" - let ref = StickerRefCodec.parse(wire) + // #require (not #expect against makeRef): if a regression made parse + // return nil here, `nil == nil` would let the test pass vacuously. + let ref = try #require(StickerRefCodec.parse(wire)) #expect(ref == makeRef(coordinate: "30031:\(pubkey):pack")) } diff --git a/docs/SONAR-STICKERS.md b/docs/SONAR-STICKERS.md index 2e842b3b..f21f9b00 100644 --- a/docs/SONAR-STICKERS.md +++ b/docs/SONAR-STICKERS.md @@ -15,6 +15,19 @@ specifies only the bitchat wire format and client behavior. The reference implementation is byte-identical to sonar-ffi's `mesh_sticker_content` / `mesh_parse_sticker_content`. +> **Status:** this lands in slices. The wire codec below is merged first as +> dead-on-arrival infrastructure — **nothing in the client parses, routes, +> fetches, or renders sticker references yet.** Parsing, consent-gated +> rendering, and opt-in sync arrive in the follow-up PRs (see the split of +> #1517). Until then, sticker content is ordinary text to every client. + +> **DM size constraint:** a maximally long reference (80-char identifier + +> 64-char shortcode) is ~290 bytes, which exceeds the current 255-byte +> private-message content limit (`PrivateMessagePacket`). Senders SHOULD keep +> the encoded reference within 255 bytes for DMs until that limit is raised +> (tracked in #784); oversized references remain valid on public mesh and +> geohash channels. + ## Wire Format ### Content Prefix @@ -46,8 +59,11 @@ Field order and count are fixed: Parsers MUST: -1. Split the content on `0x1F` with **at most 4 splits**, preserving empty - subsequences. +1. Split the content on `0x1F` into **at most 5 parts**, preserving empty + subsequences. Beware off-by-one semantics across languages: this is + Swift's `split(separator:maxSplits:)` with `maxSplits: 4` (4 splits → up + to 5 parts), but Rust's `splitn(5, ...)` — `splitn(4, ...)` yields at + most 4 parts and would reject every valid reference. 2. Accept only if the split yields **exactly 5 parts**, `parts[0]` is empty, and `parts[1] == "sticker"`. 3. Validate fields 2–4 against the table above; any violation MUST cause the