mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-18 07:33:46 +00:00
Merge ecd4da139c5072e1fd8c4bb20bff8f3175ae513e into 9b84b361225facd8e623f25d76f889d3dc54a879
This commit is contained in:
commit
55d653e272
@ -34,6 +34,10 @@ struct NostrProtocol {
|
||||
/// recipient tag (`#x`). Regular (stored) kind so it survives until
|
||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||
case courierDrop = 1401
|
||||
/// Sonar sticker-pack addressable events and the replaceable
|
||||
/// installed-pack list (sonar-sticker-pack-v1). See docs/SONAR-STICKERS.md.
|
||||
case stickerPack = 30031
|
||||
case stickerPackList = 10031
|
||||
}
|
||||
|
||||
/// Bound work before Base64-decoding either encrypted layer of an inbound
|
||||
@ -792,6 +796,28 @@ struct NostrEvent: Codable {
|
||||
self.sig = nil
|
||||
self.id = "" // Will be set during signing
|
||||
}
|
||||
|
||||
/// Construct an event with a raw integer kind (for kinds outside the
|
||||
/// EventKind enum, e.g. outbound kind-30031/10031 sticker events and unit
|
||||
/// tests). Does NOT enforce inbound tag limits; the security gate stays on
|
||||
/// init(from:) for untrusted relay input.
|
||||
init(
|
||||
id: String = "",
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
tags: [[String]],
|
||||
content: String,
|
||||
sig: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.pubkey = pubkey
|
||||
self.created_at = createdAt
|
||||
self.kind = kind
|
||||
self.tags = tags
|
||||
self.content = content
|
||||
self.sig = sig
|
||||
}
|
||||
|
||||
init(from dict: [String: Any]) throws {
|
||||
guard let pubkey = dict["pubkey"] as? String,
|
||||
@ -802,7 +828,7 @@ struct NostrEvent: Codable {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
guard Self.isWithinInboundTagLimits(tags) else {
|
||||
guard Self.isWithinInboundTagLimits(kind: kind, tags: tags) else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
@ -818,7 +844,20 @@ struct NostrEvent: Codable {
|
||||
/// Bounds untrusted relay tag arrays so attackers cannot force large
|
||||
/// allocations or expensive joins on the inbound hot path.
|
||||
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
|
||||
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
|
||||
isWithinInboundTagLimits(kind: nil, tags: tags)
|
||||
}
|
||||
|
||||
/// Kind-aware variant: Sonar sticker-pack kinds (30031/10031) may carry up
|
||||
/// to ~200 sticker tags, so they get nostrMaxStickerPackEventTags.
|
||||
static func isWithinInboundTagLimits(kind: Int?, tags: [[String]]) -> Bool {
|
||||
let stickerKinds: Set<Int> = [
|
||||
NostrProtocol.EventKind.stickerPack.rawValue,
|
||||
NostrProtocol.EventKind.stickerPackList.rawValue
|
||||
]
|
||||
let maxTags = (kind.map { stickerKinds.contains($0) } ?? false)
|
||||
? TransportConfig.nostrMaxStickerPackEventTags
|
||||
: TransportConfig.nostrMaxEventTags
|
||||
guard tags.count <= maxTags else { return false }
|
||||
|
||||
for tag in tags {
|
||||
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
|
||||
|
||||
@ -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 = [
|
||||
|
||||
93
bitchat/Protocols/StickerRefCodec.swift
Normal file
93
bitchat/Protocols/StickerRefCodec.swift
Normal file
@ -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␟<pack-coordinate>␟<shortcode>␟<plaintext-sha256>`
|
||||
/// 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>:<identifier>`.
|
||||
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>:<identifier>` 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}<coordinate>\u{1F}<shortcode>\u{1F}<sha256>`
|
||||
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])
|
||||
)
|
||||
}
|
||||
}
|
||||
294
bitchat/Services/StickerCache.swift
Normal file
294
bitchat/Services/StickerCache.swift
Normal file
@ -0,0 +1,294 @@
|
||||
//
|
||||
// StickerCache.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Shared on-disk locations for the Sonar sticker feature.
|
||||
///
|
||||
/// Everything lives under `Application Support/files/stickers/` — the same
|
||||
/// `files` tree that `BLEIncomingFileStore.panicWipe` removes, so sticker
|
||||
/// data is covered by panic wipe without any extra wiring.
|
||||
enum StickerStoragePaths {
|
||||
static func filesDirectory(
|
||||
base: URL? = nil,
|
||||
fileManager: FileManager = .default
|
||||
) throws -> URL {
|
||||
let root = try base ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
static func stickersDirectory(
|
||||
base: URL? = nil,
|
||||
fileManager: FileManager = .default
|
||||
) throws -> URL {
|
||||
let dir = try filesDirectory(base: base, fileManager: fileManager)
|
||||
.appendingPathComponent("stickers", isDirectory: true)
|
||||
try fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-addressed disk cache for sticker image bytes.
|
||||
///
|
||||
/// Layout: `Application Support/files/stickers/<sha256>[.<ext>]`. The file name
|
||||
/// is the lowercase hex SHA-256 of the plaintext (validated as strictly
|
||||
/// `[0-9a-f]{64}` before any path join, so traversal is impossible), plus an
|
||||
/// optional extension derived from the declared MIME type.
|
||||
///
|
||||
/// Last-access bookkeeping for LRU eviction lives in a small JSON sidecar
|
||||
/// (`_index.json`) inside the same directory. Chosen over extended attributes
|
||||
/// because xattrs do not survive some backup/restore and file-copy paths and
|
||||
/// are awkward to inspect in tests; a sidecar keeps all cache state in one
|
||||
/// directory that panic wipe already removes.
|
||||
///
|
||||
/// The negative cache (failed hash → next-retry timestamp, exponential backoff
|
||||
/// from 30s capped at 30min) is intentionally in-memory only: a restart simply
|
||||
/// resets backoff, which is acceptable for a cache.
|
||||
actor StickerCache {
|
||||
enum Error: Swift.Error, Equatable {
|
||||
/// Hash string is not strictly `[0-9a-f]{64}` (includes traversal attempts).
|
||||
case invalidSHA256
|
||||
/// SHA-256 of the supplied bytes does not match the claimed hash.
|
||||
case hashMismatch
|
||||
/// Sticker bytes exceed the 4 MiB hard cap.
|
||||
case stickerTooLarge
|
||||
/// A previous fetch failed recently; backoff has not elapsed yet.
|
||||
case negativeCacheBackoff(retryAfter: Date)
|
||||
}
|
||||
|
||||
static let maxStickerBytes = 4 * 1024 * 1024
|
||||
static let defaultMaxCacheBytes = 100 * 1024 * 1024
|
||||
static let negativeCacheInitialDelay: TimeInterval = 30
|
||||
static let negativeCacheMaxDelay: TimeInterval = 30 * 60
|
||||
|
||||
typealias Downloader = @Sendable (URL) async throws -> Data
|
||||
|
||||
private struct IndexEntry: Codable {
|
||||
var lastAccess: TimeInterval
|
||||
var fileExtension: String?
|
||||
var size: Int
|
||||
}
|
||||
|
||||
private let stickersDirectory: URL
|
||||
private let indexURL: URL
|
||||
private let maxCacheBytes: Int
|
||||
private let fileManager: FileManager
|
||||
private let now: @Sendable () -> Date
|
||||
|
||||
// No default value: assigned exactly once in init (a later mutation of a
|
||||
// defaulted property from the nonisolated init is an error in Swift 6 mode).
|
||||
private var index: [String: IndexEntry]
|
||||
private var inFlight: [String: Task<Data, Swift.Error>] = [:]
|
||||
private var failureCounts: [String: Int] = [:]
|
||||
private var negativeCache: [String: Date] = [:]
|
||||
private var lastIndexPersist: Date = .distantPast
|
||||
|
||||
init(
|
||||
baseDirectory: URL? = nil,
|
||||
maxCacheBytes: Int = StickerCache.defaultMaxCacheBytes,
|
||||
fileManager: FileManager = .default,
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
) {
|
||||
let dir = (try? StickerStoragePaths.stickersDirectory(base: baseDirectory, fileManager: fileManager))
|
||||
?? fileManager.temporaryDirectory
|
||||
.appendingPathComponent("bitchat-stickers-\(UUID().uuidString)", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
self.stickersDirectory = dir
|
||||
self.indexURL = dir.appendingPathComponent("_index.json", isDirectory: false)
|
||||
self.maxCacheBytes = maxCacheBytes
|
||||
self.fileManager = fileManager
|
||||
self.now = now
|
||||
// Load the LRU sidecar and drop entries whose file disappeared
|
||||
// (e.g. manual cleanup). Done inline: actor inits are nonisolated, so
|
||||
// isolated helper calls from here are an error in Swift 6 mode.
|
||||
var initialIndex: [String: IndexEntry] = [:]
|
||||
if let data = try? Data(contentsOf: self.indexURL),
|
||||
let decoded = try? JSONDecoder().decode([String: IndexEntry].self, from: data) {
|
||||
initialIndex = decoded
|
||||
}
|
||||
for (hash, entry) in initialIndex
|
||||
where !fileManager.fileExists(atPath: Self.fileURL(in: dir, forHash: hash, extension: entry.fileExtension).path) {
|
||||
initialIndex.removeValue(forKey: hash)
|
||||
}
|
||||
self.index = initialIndex
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// Returns cached bytes for a hash, or nil. Invalid hash shapes return nil
|
||||
/// (never throw, never touch the disk path builder with untrusted input).
|
||||
func data(for sha256: String) -> Data? {
|
||||
guard StickerRef.isValidSha256(sha256) else { return nil }
|
||||
let url: URL
|
||||
if let entry = index[sha256] {
|
||||
url = fileURL(forHash: sha256, extension: entry.fileExtension)
|
||||
} else {
|
||||
url = fileURL(forHash: sha256, extension: nil)
|
||||
}
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
index[sha256] = IndexEntry(
|
||||
lastAccess: now().timeIntervalSince1970,
|
||||
fileExtension: index[sha256]?.fileExtension,
|
||||
size: data.count
|
||||
)
|
||||
persistIndex()
|
||||
return data
|
||||
}
|
||||
|
||||
/// On-disk location of a cached sticker, for renderers that prefer a file
|
||||
/// URL (animated webp/gif). Nil when not cached or the hash shape is invalid.
|
||||
func fileURL(for sha256: String) -> URL? {
|
||||
guard StickerRef.isValidSha256(sha256) else { return nil }
|
||||
if let entry = index[sha256] {
|
||||
let url = fileURL(forHash: sha256, extension: entry.fileExtension)
|
||||
return fileManager.fileExists(atPath: url.path) ? url : nil
|
||||
}
|
||||
let bare = fileURL(forHash: sha256, extension: nil)
|
||||
return fileManager.fileExists(atPath: bare.path) ? bare : nil
|
||||
}
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
/// Verifies the SHA-256 of `data` BEFORE writing anything. Rejects invalid
|
||||
/// hash shapes (traversal guard) and payloads over 4 MiB.
|
||||
func store(_ data: Data, sha256: String, mime: String) throws {
|
||||
guard StickerRef.isValidSha256(sha256) else { throw Error.invalidSHA256 }
|
||||
guard data.count <= Self.maxStickerBytes else { throw Error.stickerTooLarge }
|
||||
let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
guard digest == sha256 else { throw Error.hashMismatch }
|
||||
let ext = Self.fileExtension(for: mime)
|
||||
let url = fileURL(forHash: sha256, extension: ext)
|
||||
try data.write(to: url, options: .atomic)
|
||||
index[sha256] = IndexEntry(
|
||||
lastAccess: now().timeIntervalSince1970,
|
||||
fileExtension: ext,
|
||||
size: data.count
|
||||
)
|
||||
// Mutation: persist immediately (not throttled) so a restart within
|
||||
// the 5s read-throttle window can still discover the file — the read
|
||||
// path only checks the extension-bearing name via the index.
|
||||
persistIndex(force: true)
|
||||
evictIfNeeded(protecting: sha256)
|
||||
}
|
||||
|
||||
// MARK: - Fetch with coalescing + negative cache
|
||||
|
||||
/// Returns cached bytes when present; otherwise coalesces concurrent calls
|
||||
/// per hash onto a single in-flight download, stores the result (which
|
||||
/// re-verifies the hash), and applies exponential-backoff negative caching
|
||||
/// to failures (30s initial, doubling, capped at 30min).
|
||||
func fetch(
|
||||
sha256: String,
|
||||
url: URL,
|
||||
mime: String = "",
|
||||
using downloader: @escaping Downloader
|
||||
) async throws -> Data {
|
||||
guard StickerRef.isValidSha256(sha256) else { throw Error.invalidSHA256 }
|
||||
if let cached = data(for: sha256) { return cached }
|
||||
if let retryAfter = negativeCache[sha256], now() < retryAfter {
|
||||
throw Error.negativeCacheBackoff(retryAfter: retryAfter)
|
||||
}
|
||||
if let existing = inFlight[sha256] {
|
||||
return try await existing.value
|
||||
}
|
||||
// The shared in-flight task includes verification + storage, so every
|
||||
// concurrent caller receives bytes whose hash was checked — a follower
|
||||
// must never observe the raw, unverified download.
|
||||
let task = Task { [self, downloader] in
|
||||
let data = try await downloader(url)
|
||||
try store(data, sha256: sha256, mime: mime)
|
||||
return data
|
||||
}
|
||||
inFlight[sha256] = task
|
||||
do {
|
||||
let data = try await task.value
|
||||
inFlight.removeValue(forKey: sha256)
|
||||
negativeCache.removeValue(forKey: sha256)
|
||||
failureCounts.removeValue(forKey: sha256)
|
||||
return data
|
||||
} catch {
|
||||
inFlight.removeValue(forKey: sha256)
|
||||
recordFailure(for: sha256)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wipe
|
||||
|
||||
/// Removes every cached sticker and all bookkeeping. In-flight downloads
|
||||
/// are left to finish (their `store` re-verifies and re-creates entries).
|
||||
func removeAll() {
|
||||
try? fileManager.removeItem(at: stickersDirectory)
|
||||
try? fileManager.createDirectory(at: stickersDirectory, withIntermediateDirectories: true)
|
||||
index.removeAll()
|
||||
negativeCache.removeAll()
|
||||
failureCounts.removeAll()
|
||||
lastIndexPersist = .distantPast
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
static func fileExtension(for mime: String) -> String? {
|
||||
switch mime {
|
||||
case "image/webp": return "webp"
|
||||
case "image/png", "image/apng": return "png" // APNG carries a PNG signature/container
|
||||
case "image/gif": return "gif"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func fileURL(in directory: URL, forHash hash: String, extension ext: String?) -> URL {
|
||||
let name = ext.map { "\(hash).\($0)" } ?? hash
|
||||
return directory.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
private nonisolated func fileURL(forHash hash: String, extension ext: String?) -> URL {
|
||||
Self.fileURL(in: stickersDirectory, forHash: hash, extension: ext)
|
||||
}
|
||||
|
||||
private func recordFailure(for hash: String) {
|
||||
let count = (failureCounts[hash] ?? 0) + 1
|
||||
failureCounts[hash] = count
|
||||
let delay = min(
|
||||
Self.negativeCacheInitialDelay * pow(2.0, Double(count - 1)),
|
||||
Self.negativeCacheMaxDelay
|
||||
)
|
||||
negativeCache[hash] = now().addingTimeInterval(delay)
|
||||
}
|
||||
|
||||
private func evictIfNeeded(protecting protectedHash: String? = nil) {
|
||||
var total = index.values.reduce(0) { $0 + $1.size }
|
||||
guard total > maxCacheBytes else { return }
|
||||
// Oldest last-access first.
|
||||
for (hash, entry) in index.sorted(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||
guard total > maxCacheBytes else { break }
|
||||
if hash == protectedHash { continue }
|
||||
try? fileManager.removeItem(at: fileURL(forHash: hash, extension: entry.fileExtension))
|
||||
index.removeValue(forKey: hash)
|
||||
total -= entry.size
|
||||
}
|
||||
persistIndex(force: true)
|
||||
}
|
||||
|
||||
/// Index writes are throttled on the read path (one disk write per 5s at
|
||||
/// most) and forced on mutations, so hot render loops don't thrash the disk.
|
||||
private func persistIndex(force: Bool = false) {
|
||||
guard force || now().timeIntervalSince(lastIndexPersist) > 5 else { return }
|
||||
guard let data = try? JSONEncoder().encode(index) else { return }
|
||||
try? data.write(to: indexURL, options: .atomic)
|
||||
lastIndexPersist = now()
|
||||
}
|
||||
}
|
||||
203
bitchat/Services/StickerInstallStore.swift
Normal file
203
bitchat/Services/StickerInstallStore.swift
Normal file
@ -0,0 +1,203 @@
|
||||
//
|
||||
// StickerInstallStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Local + relay-synced list of installed Sonar sticker packs (Nostr kind
|
||||
/// 10031, `a` tags of `30031:<author>:<d>` coordinates).
|
||||
///
|
||||
/// The ordered list is persisted locally as JSON under
|
||||
/// `Application Support/files/stickers/` so panic wipe covers it. Relay
|
||||
/// publishing is strictly opt-in: `syncEnabled` defaults to false and
|
||||
/// `publishInstalledList()` refuses to run while it is false — nothing about
|
||||
/// the installed list leaves the device until the user turns sync on.
|
||||
///
|
||||
/// Local mutations always succeed locally; a publish failure is logged, never
|
||||
/// rolled back.
|
||||
actor StickerInstallStore {
|
||||
enum StickerInstallError: Swift.Error, Equatable {
|
||||
/// `publishInstalledList()` was called while `syncEnabled` is false.
|
||||
case syncDisabled
|
||||
case identityUnavailable
|
||||
case invalidCoordinate
|
||||
}
|
||||
|
||||
static let listEventKind = 10031
|
||||
static let syncEnabledDefaultsKey = "stickerInstallStore.syncEnabled"
|
||||
|
||||
/// App-wide shared instance (state is on disk + UserDefaults, but one
|
||||
/// actor keeps in-memory `installed` coherent across views).
|
||||
static let shared = StickerInstallStore()
|
||||
|
||||
typealias RelayQuery = @Sendable (NostrFilter) async -> [NostrEvent]
|
||||
typealias EventPublisher = @Sendable (NostrEvent) async throws -> Void
|
||||
typealias IdentityProvider = @Sendable () throws -> NostrIdentity
|
||||
|
||||
private let storageURL: URL
|
||||
private let defaults: UserDefaults
|
||||
private let identityProvider: IdentityProvider
|
||||
private let relayQuery: RelayQuery
|
||||
private let publisher: EventPublisher
|
||||
private let fileManager: FileManager
|
||||
private let now: @Sendable () -> Date
|
||||
|
||||
/// Ordered installed pack coordinates, deduplicated in first-seen order.
|
||||
/// No default value: assigned exactly once in init (Swift 6 init isolation).
|
||||
private(set) var installed: [String]
|
||||
|
||||
/// Opt-in gate for kind-10031 list sync. Default false.
|
||||
var syncEnabled: Bool {
|
||||
get { defaults.object(forKey: Self.syncEnabledDefaultsKey) as? Bool ?? false }
|
||||
set { defaults.set(newValue, forKey: Self.syncEnabledDefaultsKey) }
|
||||
}
|
||||
|
||||
init(
|
||||
baseDirectory: URL? = nil,
|
||||
defaults: UserDefaults = .standard,
|
||||
identityProvider: @escaping IdentityProvider = {
|
||||
guard let identity = try NostrIdentityBridge().getCurrentNostrIdentity() else {
|
||||
throw StickerInstallError.identityUnavailable
|
||||
}
|
||||
return identity
|
||||
},
|
||||
relayQuery: @escaping RelayQuery = { filter in
|
||||
await StickerRelayQuery.oneShot(filter: filter)
|
||||
},
|
||||
publisher: @escaping EventPublisher = { event in
|
||||
// nil relay set = built-in default relays (never geo relays). The
|
||||
// manager's own fail-closed queueing applies while Tor bootstraps.
|
||||
await MainActor.run { NostrRelayManager.shared.sendEvent(event) }
|
||||
},
|
||||
fileManager: FileManager = .default,
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
) {
|
||||
let dir = (try? StickerStoragePaths.stickersDirectory(base: baseDirectory, fileManager: fileManager))
|
||||
?? fileManager.temporaryDirectory
|
||||
.appendingPathComponent("bitchat-stickers-\(UUID().uuidString)", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
self.storageURL = dir.appendingPathComponent("installed-packs.json", isDirectory: false)
|
||||
self.defaults = defaults
|
||||
self.identityProvider = identityProvider
|
||||
self.relayQuery = relayQuery
|
||||
self.publisher = publisher
|
||||
self.fileManager = fileManager
|
||||
self.now = now
|
||||
// Loaded inline: actor inits are nonisolated, so isolated helper calls
|
||||
// from here are an error in Swift 6 mode.
|
||||
var initial: [String] = []
|
||||
if let data = try? Data(contentsOf: self.storageURL),
|
||||
let coordinates = try? JSONDecoder().decode([String].self, from: data) {
|
||||
initial = Self.dedupePreservingOrder(
|
||||
coordinates.filter { StickerRef.isValidCoordinate($0) }
|
||||
)
|
||||
}
|
||||
self.installed = initial
|
||||
}
|
||||
|
||||
// MARK: - Local mutations
|
||||
|
||||
/// Appends a coordinate (first-seen order, no duplicates), persists, and
|
||||
/// publishes the updated list only when `syncEnabled` is true.
|
||||
func install(_ coordinate: String) async throws {
|
||||
guard StickerRef.isValidCoordinate(coordinate) else {
|
||||
throw StickerInstallError.invalidCoordinate
|
||||
}
|
||||
guard !installed.contains(coordinate) else { return }
|
||||
installed.append(coordinate)
|
||||
persist()
|
||||
await publishIfSyncEnabled()
|
||||
}
|
||||
|
||||
func uninstall(_ coordinate: String) async {
|
||||
guard installed.contains(coordinate) else { return }
|
||||
installed.removeAll { $0 == coordinate }
|
||||
persist()
|
||||
await publishIfSyncEnabled()
|
||||
}
|
||||
|
||||
func installedPacks() -> [String] { installed }
|
||||
|
||||
// MARK: - Relay sync (kind 10031)
|
||||
|
||||
/// Fetches the canonical identity's latest kind-10031 event from the
|
||||
/// default relay set and returns its `a`-tag coordinates (validated,
|
||||
/// deduplicated in first-seen order). Empty when no event exists.
|
||||
func fetchInstalledList() async throws -> [String] {
|
||||
let identity = try identityProvider()
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [Self.listEventKind]
|
||||
filter.authors = [identity.publicKeyHex]
|
||||
filter.limit = 5
|
||||
let events = await relayQuery(filter)
|
||||
guard let latest = events
|
||||
.filter({ $0.kind == Self.listEventKind && $0.pubkey == identity.publicKeyHex })
|
||||
.max(by: { $0.created_at < $1.created_at })
|
||||
else { return [] }
|
||||
let coordinates = latest.tags.compactMap { tag -> String? in
|
||||
guard tag.count >= 2, tag[0] == "a",
|
||||
StickerRef.isValidCoordinate(tag[1])
|
||||
else { return nil }
|
||||
return tag[1]
|
||||
}
|
||||
return Self.dedupePreservingOrder(coordinates)
|
||||
}
|
||||
|
||||
/// Union of local and relay state, preserving first-seen order (local
|
||||
/// entries first). Call on launch when sync is enabled.
|
||||
func mergeInstalledFromNetwork() async throws {
|
||||
let remote = try await fetchInstalledList()
|
||||
installed = Self.dedupePreservingOrder(installed + remote)
|
||||
persist()
|
||||
}
|
||||
|
||||
/// Publishes the local list as a signed kind-10031 event (empty content,
|
||||
/// `a` tags) to the default relay set. REFUSES while `syncEnabled` is
|
||||
/// false — the caller must gate this on the explicit opt-in setting.
|
||||
func publishInstalledList() async throws {
|
||||
guard syncEnabled else { throw StickerInstallError.syncDisabled }
|
||||
let identity = try identityProvider()
|
||||
let unsigned = try NostrEvent(from: [
|
||||
"pubkey": identity.publicKeyHex,
|
||||
"created_at": Int(now().timeIntervalSince1970),
|
||||
"kind": Self.listEventKind,
|
||||
"tags": installed.map { ["a", $0] },
|
||||
"content": ""
|
||||
])
|
||||
let signed = try unsigned.sign(with: identity.schnorrSigningKey())
|
||||
try await publisher(signed)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func publishIfSyncEnabled() async {
|
||||
guard syncEnabled else { return }
|
||||
do {
|
||||
try await publishInstalledList()
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"⚠️ Failed to publish sticker install list: \(error.localizedDescription)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func dedupePreservingOrder(_ coordinates: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for coordinate in coordinates where seen.insert(coordinate).inserted {
|
||||
result.append(coordinate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(installed) else { return }
|
||||
try? data.write(to: storageURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
497
bitchat/Services/StickerPackService.swift
Normal file
497
bitchat/Services/StickerPackService.swift
Normal file
@ -0,0 +1,497 @@
|
||||
//
|
||||
// StickerPackService.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Tor
|
||||
|
||||
// Validation of coordinates, shortcodes, and hashes lives in exactly one
|
||||
// place: `StickerRef` (bitchat/Protocols/StickerRefCodec.swift), the wire
|
||||
// codec. Pack parsing round-trips candidate values through the codec's
|
||||
// validators so the pack layer and the wire layer can never drift apart —
|
||||
// a pack is only installable when every sticker shortcode it declares can
|
||||
// actually be encoded into a wire reference.
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct StickerDimensions: Codable, Equatable, Sendable {
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
struct Sticker: Codable, Equatable, Sendable, Identifiable {
|
||||
let shortcode: String
|
||||
let url: URL
|
||||
/// Lowercase hex SHA-256 of the plaintext bytes (also present in `url.path`).
|
||||
let sha256: String
|
||||
/// One of image/webp, image/png, image/apng, image/gif.
|
||||
let mime: String
|
||||
let dim: StickerDimensions?
|
||||
let alt: String?
|
||||
let emoji: String?
|
||||
|
||||
var id: String { shortcode }
|
||||
}
|
||||
|
||||
struct StickerPack: Codable, Equatable, Sendable, Identifiable {
|
||||
/// `30031:<author-pubkey-hex>:<d-identifier>`.
|
||||
let coordinate: String
|
||||
let authorPubkey: String
|
||||
let identifier: String
|
||||
let title: String
|
||||
let description: String?
|
||||
let coverURL: URL?
|
||||
let createdAt: Date
|
||||
let stickers: [Sticker]
|
||||
|
||||
var id: String { coordinate }
|
||||
}
|
||||
|
||||
// MARK: - One-shot relay queries
|
||||
|
||||
/// One-shot, EOSE-bounded Nostr queries used by the sticker services.
|
||||
///
|
||||
/// Goes through `NostrRelayManager.shared`, so the global network-activation
|
||||
/// gate and the manager's own Tor fail-closed queueing apply; `relayUrls: nil`
|
||||
/// targets the built-in default relay set (damus/nos.lol/primal/offchain +
|
||||
/// user-added), never the geo relays.
|
||||
enum StickerRelayQuery {
|
||||
@MainActor
|
||||
static func oneShot(
|
||||
filter: NostrFilter,
|
||||
timeout: TimeInterval = 10
|
||||
) async -> [NostrEvent] {
|
||||
await withCheckedContinuation { continuation in
|
||||
let query = StickerOneShotQuery(continuation: continuation)
|
||||
query.start(filter: filter, timeout: timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class StickerOneShotQuery {
|
||||
private var events: [NostrEvent] = []
|
||||
private var continuation: CheckedContinuation<[NostrEvent], Never>?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
/// Deliberate self-retain. The relay handlers and the timeout task capture
|
||||
/// `self` weakly, so without this the query would deallocate as soon as
|
||||
/// the `withCheckedContinuation` closure in `oneShot` returns — and
|
||||
/// neither EOSE nor the timeout could ever resume the continuation,
|
||||
/// hanging every uncached pack lookup forever. Broken in `finish`.
|
||||
private var selfRetain: StickerOneShotQuery?
|
||||
|
||||
init(continuation: CheckedContinuation<[NostrEvent], Never>) {
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
func start(filter: NostrFilter, timeout: TimeInterval) {
|
||||
selfRetain = self
|
||||
let id = "sticker-oneshot-\(UUID().uuidString)"
|
||||
NostrRelayManager.shared.subscribe(
|
||||
filter: filter,
|
||||
id: id,
|
||||
relayUrls: nil,
|
||||
handler: { [weak self] event in
|
||||
Task { @MainActor in self?.events.append(event) }
|
||||
},
|
||||
onEOSE: { [weak self] in
|
||||
Task { @MainActor in self?.finish(id: id) }
|
||||
}
|
||||
)
|
||||
timeoutTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
self?.finish(id: id) // no-op if EOSE already finished
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(id: String) {
|
||||
guard let continuation else { return }
|
||||
self.continuation = nil
|
||||
timeoutTask?.cancel()
|
||||
NostrRelayManager.shared.unsubscribe(id: id)
|
||||
continuation.resume(returning: events)
|
||||
selfRetain = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
/// Resolves Sonar sticker packs (Nostr kind 30031, format
|
||||
/// `sonar-sticker-pack-v1`) and downloads/caches sticker images.
|
||||
///
|
||||
/// Rendering never hits the network synchronously: pack metadata is cached in
|
||||
/// memory (plus a small JSON sidecar) with a 24h TTL, and image bytes come
|
||||
/// from `StickerCache` whenever possible. All internet access is Tor-aware and
|
||||
/// fail-closed (see `makeTorDownloader`).
|
||||
@MainActor
|
||||
final class StickerPackService: ObservableObject {
|
||||
typealias RelayQuery = @Sendable (NostrFilter) async -> [NostrEvent]
|
||||
typealias Downloader = @Sendable (URL) async throws -> Data
|
||||
|
||||
enum StickerPackError: Swift.Error, Equatable {
|
||||
case packNotFound
|
||||
case invalidCoordinate
|
||||
/// Network activation gate is closed (no permission/favorites/offline).
|
||||
case networkNotAllowed
|
||||
/// Tor preference is on but Tor failed to become ready. Fail-closed:
|
||||
/// the fetch is refused rather than leaked to clearnet.
|
||||
case torNotReady
|
||||
/// A recent fetch for this pack came up empty; exponential backoff
|
||||
/// has not elapsed yet. In-memory only (a restart resets it).
|
||||
case packFetchBackoff(retryAfter: Date)
|
||||
case downloadTooLarge
|
||||
case mimeMismatch
|
||||
case invalidImageData
|
||||
}
|
||||
|
||||
nonisolated static let packEventKind = 30031
|
||||
nonisolated static let packFormatValue = "sonar-sticker-pack-v1"
|
||||
nonisolated static let maxStickersPerPack = 200
|
||||
nonisolated static let maxImageDimension = 4096
|
||||
nonisolated static let metadataTTL: TimeInterval = 24 * 60 * 60
|
||||
nonisolated static let allowedMimes: Set<String> = ["image/webp", "image/png", "image/apng", "image/gif"]
|
||||
|
||||
static let shared = StickerPackService()
|
||||
|
||||
/// Pack metadata keyed by coordinate, for synchronous render lookups.
|
||||
@Published private(set) var packs: [String: StickerPack] = [:]
|
||||
private var packFetchedAt: [String: Date] = [:]
|
||||
/// Negative cache for pack fetches: coordinate → do-not-retry-until.
|
||||
/// In-memory only (a restart simply resets backoff), mirroring
|
||||
/// `StickerCache`'s failure policy.
|
||||
private var packFailureCounts: [String: Int] = [:]
|
||||
private var packNegativeCache: [String: Date] = [:]
|
||||
|
||||
private let cache: StickerCache
|
||||
private let relayQuery: RelayQuery
|
||||
private let downloader: Downloader
|
||||
private let networkActivationAllowed: @Sendable () async -> Bool
|
||||
private let now: @Sendable () -> Date
|
||||
private let metadataURL: URL?
|
||||
|
||||
init(
|
||||
cache: StickerCache = StickerCache(),
|
||||
relayQuery: @escaping RelayQuery = { filter in
|
||||
await StickerRelayQuery.oneShot(filter: filter)
|
||||
},
|
||||
downloader: @escaping Downloader = StickerPackService.makeTorDownloader(),
|
||||
networkActivationAllowed: @escaping @Sendable () async -> Bool = {
|
||||
await NetworkActivationService.shared.activationAllowed
|
||||
},
|
||||
metadataURL: URL? = try? StickerStoragePaths.stickersDirectory()
|
||||
.appendingPathComponent("pack-metadata.json", isDirectory: false),
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
) {
|
||||
self.cache = cache
|
||||
self.relayQuery = relayQuery
|
||||
self.downloader = downloader
|
||||
self.networkActivationAllowed = networkActivationAllowed
|
||||
self.metadataURL = metadataURL
|
||||
self.now = now
|
||||
loadMetadata()
|
||||
}
|
||||
|
||||
// MARK: - Pack event parsing (pure, spec-validating)
|
||||
|
||||
/// Parses and fully validates a kind-30031 `sonar-sticker-pack-v1` event.
|
||||
/// Any spec violation rejects the whole pack (returns nil).
|
||||
nonisolated static func parsePackEvent(_ event: NostrEvent) -> StickerPack? {
|
||||
guard event.kind == packEventKind else { return nil }
|
||||
guard StickerRef.isValidSha256(event.pubkey) else { return nil }
|
||||
guard event.tags.contains(where: {
|
||||
$0.count >= 2 && $0[0] == "pack_format" && $0[1] == packFormatValue
|
||||
}) else { return nil }
|
||||
|
||||
guard let dTag = event.tags.first(where: { $0.count >= 2 && $0[0] == "d" }) else { return nil }
|
||||
let identifier = dTag[1]
|
||||
let coordinate = "30031:\(event.pubkey):\(identifier)"
|
||||
// Round-trip through the wire codec: a pack is only valid when its
|
||||
// coordinate is exactly one a StickerRef can express.
|
||||
guard StickerRef.isValidCoordinate(coordinate) else { return nil }
|
||||
|
||||
guard let titleTag = event.tags.first(where: { $0.count >= 2 && $0[0] == "title" }),
|
||||
titleTag[1].utf8.count <= 256,
|
||||
!titleTag[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else { return nil }
|
||||
|
||||
let description = event.tags.first(where: { $0.count >= 2 && $0[0] == "description" })?[1]
|
||||
|
||||
var coverURL: URL? = nil
|
||||
if let imageTag = event.tags.first(where: { $0.count >= 2 && $0[0] == "image" }) {
|
||||
guard let url = validatedHTTPSURL(imageTag[1]) else { return nil }
|
||||
coverURL = url
|
||||
}
|
||||
|
||||
let stickerTags = event.tags.filter { $0.count >= 2 && $0[0] == "sticker" }
|
||||
guard (1...maxStickersPerPack).contains(stickerTags.count) else { return nil }
|
||||
|
||||
var seenShortcodes = Set<String>()
|
||||
var seenHashes = Set<String>()
|
||||
var stickers: [Sticker] = []
|
||||
stickers.reserveCapacity(stickerTags.count)
|
||||
|
||||
for tag in stickerTags {
|
||||
// ["sticker", shortcode, url, sha256, mime, dim?, alt?, emoji?]
|
||||
guard tag.count >= 5 else { return nil }
|
||||
let shortcode = tag[1]
|
||||
let hash = tag[3]
|
||||
let mime = tag[4]
|
||||
guard StickerRef.isValidShortcode(shortcode) else { return nil }
|
||||
guard StickerRef.isValidSha256(hash) else { return nil }
|
||||
guard allowedMimes.contains(mime) else { return nil }
|
||||
guard let url = validatedStickerURL(tag[2], sha256: hash) else { return nil }
|
||||
|
||||
var dim: StickerDimensions? = nil
|
||||
if tag.count >= 6, !tag[5].isEmpty {
|
||||
guard let parsed = parseDimensions(tag[5]) else { return nil }
|
||||
dim = parsed
|
||||
}
|
||||
let alt = tag.count >= 7 ? tag[6] : nil
|
||||
let emoji = tag.count >= 8 ? tag[7] : nil
|
||||
|
||||
guard seenShortcodes.insert(shortcode).inserted else { return nil }
|
||||
guard seenHashes.insert(hash).inserted else { return nil }
|
||||
stickers.append(Sticker(
|
||||
shortcode: shortcode, url: url, sha256: hash,
|
||||
mime: mime, dim: dim, alt: alt, emoji: emoji
|
||||
))
|
||||
}
|
||||
|
||||
return StickerPack(
|
||||
coordinate: coordinate,
|
||||
authorPubkey: event.pubkey,
|
||||
identifier: identifier,
|
||||
title: titleTag[1],
|
||||
description: description,
|
||||
coverURL: coverURL,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
stickers: stickers
|
||||
)
|
||||
}
|
||||
|
||||
/// HTTPS-only, no embedded credentials.
|
||||
nonisolated static func validatedHTTPSURL(_ raw: String) -> URL? {
|
||||
guard let url = URL(string: raw),
|
||||
url.scheme?.lowercased() == "https",
|
||||
url.host != nil,
|
||||
url.user == nil, url.password == nil
|
||||
else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
/// Sticker asset URL: HTTPS-only and the path must contain the lowercase
|
||||
/// sha256 (binds the URL to the pinned hash).
|
||||
nonisolated static func validatedStickerURL(_ raw: String, sha256: String) -> URL? {
|
||||
guard let url = validatedHTTPSURL(raw),
|
||||
url.path.lowercased().contains(sha256)
|
||||
else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
/// `WxH` with both dimensions in 1...4096.
|
||||
nonisolated static func parseDimensions(_ raw: String) -> StickerDimensions? {
|
||||
let parts = raw.split(separator: "x", omittingEmptySubsequences: false)
|
||||
guard parts.count == 2,
|
||||
let w = Int(parts[0]), let h = Int(parts[1]),
|
||||
(1...maxImageDimension).contains(w),
|
||||
(1...maxImageDimension).contains(h)
|
||||
else { return nil }
|
||||
return StickerDimensions(width: w, height: h)
|
||||
}
|
||||
|
||||
// MARK: - Pack fetching
|
||||
|
||||
/// Fetches a pack by author + identifier, preferring the 24h metadata
|
||||
/// cache. Relay queries are one-shot (EOSE-bounded, ~10s timeout) on the
|
||||
/// built-in default relay set; the latest `created_at` wins. Misses are
|
||||
/// negative-cached with exponential backoff (30s → 30min, in-memory) so a
|
||||
/// missing/dead pack is not re-queried on every render — each retry would
|
||||
/// be a repeat read beacon to the relays.
|
||||
func fetchPack(authorPubkeyHex: String, identifier: String) async throws -> StickerPack {
|
||||
let coordinate = "30031:\(authorPubkeyHex):\(identifier)"
|
||||
// Round-trip through the wire codec (see parsePackEvent).
|
||||
guard StickerRef.isValidCoordinate(coordinate)
|
||||
else { throw StickerPackError.invalidCoordinate }
|
||||
|
||||
if let cached = packs[coordinate],
|
||||
let fetchedAt = packFetchedAt[coordinate],
|
||||
now().timeIntervalSince(fetchedAt) < Self.metadataTTL {
|
||||
return cached
|
||||
}
|
||||
|
||||
if let retryAfter = packNegativeCache[coordinate], now() < retryAfter {
|
||||
throw StickerPackError.packFetchBackoff(retryAfter: retryAfter)
|
||||
}
|
||||
|
||||
// NOTE: NostrFilter.tagFilters is fileprivate, so a server-side "#d"
|
||||
// filter cannot be expressed from this file. We query by kind+author
|
||||
// and filter the identifier client-side. A follow-up could add a
|
||||
// `NostrFilter.stickerPack(author:identifier:)` factory next to the
|
||||
// other factories in NostrRelayManager.swift.
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [Self.packEventKind]
|
||||
filter.authors = [authorPubkeyHex]
|
||||
filter.limit = 20
|
||||
|
||||
let events = await relayQuery(filter)
|
||||
let candidates = events
|
||||
.compactMap(Self.parsePackEvent)
|
||||
.filter { $0.authorPubkey == authorPubkeyHex && $0.identifier == identifier }
|
||||
guard let latest = candidates.max(by: { $0.createdAt < $1.createdAt }) else {
|
||||
recordPackFailure(for: coordinate)
|
||||
throw StickerPackError.packNotFound
|
||||
}
|
||||
|
||||
packs[coordinate] = latest
|
||||
packFetchedAt[coordinate] = now()
|
||||
packFailureCounts.removeValue(forKey: coordinate)
|
||||
packNegativeCache.removeValue(forKey: coordinate)
|
||||
persistMetadata()
|
||||
return latest
|
||||
}
|
||||
|
||||
/// In-memory exponential backoff for failed pack fetches (30s initial,
|
||||
/// doubling, capped at 30min — same policy as `StickerCache`).
|
||||
private func recordPackFailure(for coordinate: String) {
|
||||
let count = (packFailureCounts[coordinate] ?? 0) + 1
|
||||
packFailureCounts[coordinate] = count
|
||||
let delay = min(
|
||||
StickerCache.negativeCacheInitialDelay * pow(2.0, Double(count - 1)),
|
||||
StickerCache.negativeCacheMaxDelay
|
||||
)
|
||||
packNegativeCache[coordinate] = now().addingTimeInterval(delay)
|
||||
}
|
||||
|
||||
/// Synchronous cache-only lookup for renderers — never queries the network.
|
||||
func cachedPack(forCoordinate coordinate: String) -> StickerPack? {
|
||||
packs[coordinate]
|
||||
}
|
||||
|
||||
// MARK: - Image fetching
|
||||
|
||||
/// Returns sticker bytes from the disk cache when present; otherwise
|
||||
/// downloads through the Tor-aware fail-closed pipeline (network-activation
|
||||
/// gate → streamed 4 MiB cap → MIME sniff vs declared → CGImageSource
|
||||
/// dimension check) and stores them in `StickerCache`, which re-verifies
|
||||
/// the sha256 and applies negative-cache backoff to failures.
|
||||
func imageData(for sticker: Sticker) async throws -> Data {
|
||||
if let data = await cache.data(for: sticker.sha256) { return data }
|
||||
return try await cache.fetch(
|
||||
sha256: sticker.sha256,
|
||||
url: sticker.url,
|
||||
mime: sticker.mime
|
||||
) { [downloader, networkActivationAllowed] url in
|
||||
guard await networkActivationAllowed() else {
|
||||
throw StickerPackError.networkNotAllowed
|
||||
}
|
||||
let data = try await downloader(url)
|
||||
try Self.validateDownloadedImage(data, declaredMime: sticker.mime)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack-edit attack guard: a ref is only trustworthy when the CURRENT pack
|
||||
/// still contains BOTH the shortcode and the pinned plaintext hash. When
|
||||
/// this is false the caller must render an "untrusted sticker" placeholder.
|
||||
nonisolated func validateStickerRef(
|
||||
packCoordinate: String,
|
||||
shortcode: String,
|
||||
plaintextSha256: String,
|
||||
against pack: StickerPack
|
||||
) -> Bool {
|
||||
guard pack.coordinate == packCoordinate else { return false }
|
||||
return pack.stickers.contains {
|
||||
$0.shortcode == shortcode && $0.sha256 == plaintextSha256
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download validation
|
||||
|
||||
/// Size cap, MIME byte-sniff vs declared type (APNG sniffs as PNG — both
|
||||
/// carry the PNG signature), and CGImageSource dimension check (≤4096²).
|
||||
nonisolated static func validateDownloadedImage(_ data: Data, declaredMime: String) throws {
|
||||
guard !data.isEmpty, data.count <= StickerCache.maxStickerBytes else {
|
||||
throw StickerPackError.downloadTooLarge
|
||||
}
|
||||
let sniffOK: Bool
|
||||
switch declaredMime {
|
||||
case "image/webp": sniffOK = MimeType.webp.matches(data: data)
|
||||
case "image/png", "image/apng": sniffOK = MimeType.png.matches(data: data)
|
||||
case "image/gif": sniffOK = MimeType.gif.matches(data: data)
|
||||
default: sniffOK = false
|
||||
}
|
||||
guard sniffOK else { throw StickerPackError.mimeMismatch }
|
||||
|
||||
let options = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, options),
|
||||
CGImageSourceGetType(source) != nil,
|
||||
let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = props[kCGImagePropertyPixelWidth] as? Int,
|
||||
let height = props[kCGImagePropertyPixelHeight] as? Int,
|
||||
width >= 1, height >= 1,
|
||||
width <= maxImageDimension, height <= maxImageDimension
|
||||
else { throw StickerPackError.invalidImageData }
|
||||
}
|
||||
|
||||
/// Default downloader: all traffic through `TorURLSession.shared.session`,
|
||||
/// fail-closed on the Tor preference (mirrors GeoRelayDirectory): when Tor
|
||||
/// is wanted but not ready, the fetch is refused — never clearnet.
|
||||
/// Content-Length is not trusted; bytes are counted as they arrive.
|
||||
nonisolated static func makeTorDownloader() -> Downloader {
|
||||
{ url in
|
||||
if NetworkActivationService.persistedTorPreference() {
|
||||
guard await TorManager.shared.awaitReady() else {
|
||||
throw StickerPackError.torNotReady
|
||||
}
|
||||
}
|
||||
let session = TorURLSession.shared.session
|
||||
let (bytes, response) = try await session.bytes(from: url)
|
||||
guard let http = response as? HTTPURLResponse,
|
||||
(200...299).contains(http.statusCode)
|
||||
else { throw URLError(.badServerResponse) }
|
||||
var data = Data()
|
||||
for try await byte in bytes {
|
||||
guard data.count < StickerCache.maxStickerBytes else {
|
||||
throw StickerPackError.downloadTooLarge
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata persistence (24h TTL, small JSON sidecar)
|
||||
|
||||
private struct PersistedMetadata: Codable {
|
||||
var packs: [String: StickerPack]
|
||||
var fetchedAt: [String: Date]
|
||||
}
|
||||
|
||||
private func persistMetadata() {
|
||||
guard let metadataURL,
|
||||
let data = try? JSONEncoder().encode(
|
||||
PersistedMetadata(packs: packs, fetchedAt: packFetchedAt)
|
||||
) else { return }
|
||||
try? data.write(to: metadataURL, options: .atomic)
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
guard let metadataURL,
|
||||
let data = try? Data(contentsOf: metadataURL),
|
||||
let decoded = try? JSONDecoder().decode(PersistedMetadata.self, from: data)
|
||||
else { return }
|
||||
let reference = now()
|
||||
for (coordinate, fetchedAt) in decoded.fetchedAt
|
||||
where reference.timeIntervalSince(fetchedAt) < Self.metadataTTL {
|
||||
guard let pack = decoded.packs[coordinate] else { continue }
|
||||
packs[coordinate] = pack
|
||||
packFetchedAt[coordinate] = fetchedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -98,6 +98,9 @@ enum TransportConfig {
|
||||
// Reject oversized/untrusted relay frames before JSON parse / store.
|
||||
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
|
||||
static let nostrMaxEventTags: Int = 64
|
||||
// Sonar sticker packs can carry up to 200 stickers (1 tag each) plus
|
||||
// metadata, so kind 30031/10031 events get a higher inbound tag cap.
|
||||
static let nostrMaxStickerPackEventTags: Int = 220
|
||||
static let nostrMaxEventTagValues: Int = 16
|
||||
static let nostrMaxEventTagValueBytes: Int = 1024
|
||||
// Bounded per-relay inbound frame buffer. Each relay connection owns its
|
||||
|
||||
148
bitchatTests/Protocols/StickerRefCodecTests.swift
Normal file
148
bitchatTests/Protocols/StickerRefCodecTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
285
bitchatTests/Services/StickerCacheTests.swift
Normal file
285
bitchatTests/Services/StickerCacheTests.swift
Normal file
@ -0,0 +1,285 @@
|
||||
//
|
||||
// StickerCacheTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// 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
|
||||
|
||||
@Suite("StickerCache Tests")
|
||||
struct StickerCacheTests {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private final class MutableClock: @unchecked Sendable {
|
||||
var current: Date = Date(timeIntervalSince1970: 1_000)
|
||||
func now() -> Date { current }
|
||||
}
|
||||
|
||||
private final class Counter: @unchecked Sendable {
|
||||
var value = 0
|
||||
}
|
||||
|
||||
private func makeTempDir() -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("sticker-cache-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func cleanup(_ url: URL) {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Test("store → data round-trip returns identical bytes")
|
||||
func storeThenReadRoundTrip() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
|
||||
let bytes = Data("hello sticker".utf8)
|
||||
let hash = sha256Hex(bytes)
|
||||
try await cache.store(bytes, sha256: hash, mime: "image/webp")
|
||||
|
||||
let read = await cache.data(for: hash)
|
||||
#expect(read == bytes)
|
||||
// Extension is derived from the MIME type.
|
||||
let fileURL = await cache.fileURL(for: hash)
|
||||
#expect(fileURL?.lastPathComponent == "\(hash).webp")
|
||||
}
|
||||
|
||||
@Test("mismatched hash throws before writing and leaves no file")
|
||||
func mismatchedHashRejectedBeforeWrite() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
|
||||
let bytes = Data("actual bytes".utf8)
|
||||
let wrongHash = String(repeating: "f", count: 64)
|
||||
|
||||
await #expect(throws: StickerCache.Error.hashMismatch) {
|
||||
try await cache.store(bytes, sha256: wrongHash, mime: "image/png")
|
||||
}
|
||||
|
||||
let stickersDir = dir.appendingPathComponent("files/stickers", isDirectory: true)
|
||||
let contents = (try? FileManager.default.contentsOfDirectory(atPath: stickersDir.path)) ?? []
|
||||
#expect(contents.isEmpty)
|
||||
#expect(await cache.data(for: wrongHash) == nil)
|
||||
}
|
||||
|
||||
@Test("payloads over 4 MiB are rejected")
|
||||
func oversizedRejected() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
|
||||
let big = Data(repeating: 0xAB, count: StickerCache.maxStickerBytes + 1)
|
||||
let hash = sha256Hex(big)
|
||||
await #expect(throws: StickerCache.Error.stickerTooLarge) {
|
||||
try await cache.store(big, sha256: hash, mime: "image/webp")
|
||||
}
|
||||
#expect(await cache.data(for: hash) == nil)
|
||||
}
|
||||
|
||||
@Test("path-traversal and malformed hash strings are rejected")
|
||||
func traversalRejected() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
let bytes = Data("x".utf8)
|
||||
|
||||
let bad = [
|
||||
"../\(String(repeating: "a", count: 61))",
|
||||
"\(String(repeating: "a", count: 32))/../\(String(repeating: "b", count: 27))",
|
||||
String(repeating: "A", count: 64), // uppercase is not a valid file name
|
||||
String(repeating: "g", count: 64), // not hex
|
||||
String(repeating: "a", count: 63), // too short
|
||||
"a/a" + String(repeating: "0", count: 61)
|
||||
]
|
||||
for hash in bad {
|
||||
await #expect(throws: StickerCache.Error.invalidSHA256) {
|
||||
try await cache.store(bytes, sha256: hash, mime: "image/png")
|
||||
}
|
||||
#expect(await cache.data(for: hash) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("removeAll clears cached bytes and negative-cache state")
|
||||
func removeAllClears() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
|
||||
let bytes = Data("wipe me".utf8)
|
||||
let hash = sha256Hex(bytes)
|
||||
try await cache.store(bytes, sha256: hash, mime: "image/gif")
|
||||
#expect(await cache.data(for: hash) != nil)
|
||||
|
||||
await cache.removeAll()
|
||||
#expect(await cache.data(for: hash) == nil)
|
||||
#expect(await cache.fileURL(for: hash) == nil)
|
||||
}
|
||||
|
||||
@Test("LRU eviction removes the oldest last-access entry first")
|
||||
func lruEvictsOldestAccess() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let clock = MutableClock()
|
||||
// Cap of 100 bytes: three 40-byte stickers cannot all fit.
|
||||
let cache = StickerCache(baseDirectory: dir, maxCacheBytes: 100, now: clock.now)
|
||||
|
||||
let aBytes = Data(repeating: 0xA1, count: 40)
|
||||
let bBytes = Data(repeating: 0xB2, count: 40)
|
||||
let cBytes = Data(repeating: 0xC3, count: 40)
|
||||
let a = sha256Hex(aBytes)
|
||||
let b = sha256Hex(bBytes)
|
||||
let c = sha256Hex(cBytes)
|
||||
|
||||
clock.current = Date(timeIntervalSince1970: 1)
|
||||
try await cache.store(aBytes, sha256: a, mime: "image/png")
|
||||
clock.current = Date(timeIntervalSince1970: 2)
|
||||
try await cache.store(bBytes, sha256: b, mime: "image/png")
|
||||
// Touch A so B becomes the oldest last-access entry.
|
||||
clock.current = Date(timeIntervalSince1970: 3)
|
||||
_ = await cache.data(for: a)
|
||||
clock.current = Date(timeIntervalSince1970: 4)
|
||||
try await cache.store(cBytes, sha256: c, mime: "image/png")
|
||||
|
||||
#expect(await cache.data(for: b) == nil) // evicted
|
||||
#expect(await cache.data(for: a) != nil)
|
||||
#expect(await cache.data(for: c) != nil)
|
||||
}
|
||||
|
||||
@Test("negative cache blocks immediate refetch after a failure")
|
||||
func negativeCacheBackoffBlocksRefetch() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
|
||||
let hash = String(repeating: "1", count: 64)
|
||||
let url = URL(string: "https://cdn.example.com/stickers/\(hash).webp")!
|
||||
let calls = Counter()
|
||||
|
||||
await #expect(throws: URLError.self) {
|
||||
_ = try await cache.fetch(sha256: hash, url: url) { _ in
|
||||
calls.value += 1
|
||||
throw URLError(.notConnectedToInternet)
|
||||
}
|
||||
}
|
||||
#expect(calls.value == 1)
|
||||
|
||||
// Immediate retry must short-circuit without invoking the downloader.
|
||||
do {
|
||||
_ = try await cache.fetch(sha256: hash, url: url) { _ in
|
||||
calls.value += 1
|
||||
return Data()
|
||||
}
|
||||
Issue.record("expected negativeCacheBackoff error")
|
||||
} catch let StickerCache.Error.negativeCacheBackoff(retryAfter) {
|
||||
#expect(retryAfter > Date())
|
||||
}
|
||||
#expect(calls.value == 1)
|
||||
}
|
||||
|
||||
@Test("concurrent fetches for the same hash coalesce onto one download")
|
||||
func fetchCoalescesInFlight() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
|
||||
let bytes = Data("coalesced".utf8)
|
||||
let hash = sha256Hex(bytes)
|
||||
let url = URL(string: "https://cdn.example.com/stickers/\(hash).webp")!
|
||||
let calls = Counter()
|
||||
|
||||
async let first = cache.fetch(sha256: hash, url: url, mime: "image/webp") { _ in
|
||||
calls.value += 1
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
return bytes
|
||||
}
|
||||
async let second = cache.fetch(sha256: hash, url: url, mime: "image/webp") { _ in
|
||||
calls.value += 1
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
return bytes
|
||||
}
|
||||
|
||||
let results = try await [first, second]
|
||||
#expect(results == [bytes, bytes])
|
||||
#expect(calls.value == 1)
|
||||
// And the result was persisted.
|
||||
#expect(await cache.data(for: hash) == bytes)
|
||||
}
|
||||
|
||||
@Test("concurrent followers never receive unverified bytes: a hash mismatch fails every waiter")
|
||||
func concurrentFollowersObserveValidationFailure() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let cache = StickerCache(baseDirectory: dir)
|
||||
|
||||
let pinned = String(repeating: "1", count: 64)
|
||||
let url = URL(string: "https://cdn.example.com/stickers/\(pinned).webp")!
|
||||
let calls = Counter()
|
||||
|
||||
let downloader: StickerCache.Downloader = { _ in
|
||||
calls.value += 1
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
return Data("tampered".utf8) // does not hash to `pinned`
|
||||
}
|
||||
|
||||
async let first = cache.fetch(sha256: pinned, url: url, mime: "image/webp", using: downloader)
|
||||
async let second = cache.fetch(sha256: pinned, url: url, mime: "image/webp", using: downloader)
|
||||
|
||||
// Verification+storage happens INSIDE the shared in-flight task, so
|
||||
// the follower cannot render the raw, unverified payload: it observes
|
||||
// the same hashMismatch as the caller that initiated the download.
|
||||
do {
|
||||
_ = try await first
|
||||
Issue.record("expected hashMismatch for the initiating fetch")
|
||||
} catch StickerCache.Error.hashMismatch {} catch {
|
||||
Issue.record("expected hashMismatch, got \(error)")
|
||||
}
|
||||
do {
|
||||
_ = try await second
|
||||
Issue.record("expected hashMismatch for the coalesced follower")
|
||||
} catch StickerCache.Error.hashMismatch {} catch {
|
||||
Issue.record("expected hashMismatch, got \(error)")
|
||||
}
|
||||
#expect(calls.value == 1)
|
||||
#expect(await cache.data(for: pinned) == nil)
|
||||
}
|
||||
|
||||
@Test("store persists the index immediately: a restart inside the throttle window still discovers files")
|
||||
func storePersistsIndexWithinThrottleWindow() async throws {
|
||||
let dir = makeTempDir()
|
||||
defer { cleanup(dir) }
|
||||
let clock = MutableClock()
|
||||
let cache = StickerCache(baseDirectory: dir, now: clock.now)
|
||||
|
||||
let bytesA = Data("alpha".utf8)
|
||||
let hashA = sha256Hex(bytesA)
|
||||
try await cache.store(bytesA, sha256: hashA, mime: "image/webp")
|
||||
// Second store lands inside the 5s read-path throttle window.
|
||||
clock.current = clock.current.addingTimeInterval(1)
|
||||
let bytesB = Data("bravo".utf8)
|
||||
let hashB = sha256Hex(bytesB)
|
||||
try await cache.store(bytesB, sha256: hashB, mime: "image/webp")
|
||||
|
||||
// Simulated restart: a fresh cache over the same directory must
|
||||
// discover BOTH extension-bearing files through the persisted index —
|
||||
// the read path only finds `<hash>.<ext>` via an index entry.
|
||||
let restarted = StickerCache(baseDirectory: dir, now: clock.now)
|
||||
#expect(await restarted.data(for: hashA) == bytesA)
|
||||
#expect(await restarted.data(for: hashB) == bytesB)
|
||||
}
|
||||
}
|
||||
111
bitchatTests/Services/StickerPackFetchTests.swift
Normal file
111
bitchatTests/Services/StickerPackFetchTests.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// StickerPackFetchTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Fetch-path tests for `StickerPackService` with injected relay query,
|
||||
/// downloader, and clock — no relay, Tor, or network access.
|
||||
@Suite("Sticker Pack Fetch Tests")
|
||||
struct StickerPackFetchTests {
|
||||
|
||||
private final class Counter: @unchecked Sendable { var value = 0 }
|
||||
private final class MutableClock: @unchecked Sendable {
|
||||
var current = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
func now() -> Date { current }
|
||||
}
|
||||
|
||||
private let pubkey = String(repeating: "a", count: 64)
|
||||
|
||||
@MainActor
|
||||
private func makeService(
|
||||
clock: MutableClock,
|
||||
relayQuery: @escaping StickerPackService.RelayQuery
|
||||
) -> StickerPackService {
|
||||
StickerPackService(
|
||||
cache: StickerCache(
|
||||
baseDirectory: FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("sticker-pack-fetch-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
),
|
||||
relayQuery: relayQuery,
|
||||
downloader: { _ in throw URLError(.notConnectedToInternet) },
|
||||
networkActivationAllowed: { true },
|
||||
metadataURL: nil,
|
||||
now: clock.now
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("missing pack is negative-cached: repeats back off instead of re-querying relays")
|
||||
func missingPackIsNegativeCached() async {
|
||||
let clock = MutableClock()
|
||||
let calls = Counter()
|
||||
let service = makeService(clock: clock) { _ in
|
||||
calls.value += 1
|
||||
return []
|
||||
}
|
||||
|
||||
await #expect(throws: StickerPackService.StickerPackError.packNotFound) {
|
||||
try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope")
|
||||
}
|
||||
#expect(calls.value == 1)
|
||||
|
||||
// Immediate retry short-circuits into backoff — no second relay query.
|
||||
// (Every retry would be a repeat read beacon to the relay set.)
|
||||
do {
|
||||
_ = try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope")
|
||||
Issue.record("expected packFetchBackoff")
|
||||
} catch let StickerPackService.StickerPackError.packFetchBackoff(retryAfter) {
|
||||
#expect(retryAfter > clock.current)
|
||||
} catch {
|
||||
Issue.record("expected packFetchBackoff, got \(error)")
|
||||
}
|
||||
#expect(calls.value == 1)
|
||||
|
||||
// After the initial 30s backoff elapses the relay is queried again,
|
||||
// and the repeated miss backs off further.
|
||||
clock.current = clock.current.addingTimeInterval(31)
|
||||
await #expect(throws: StickerPackService.StickerPackError.packNotFound) {
|
||||
try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope")
|
||||
}
|
||||
#expect(calls.value == 2)
|
||||
|
||||
// Third failure doubled the backoff: +45s is still inside the 60s window.
|
||||
clock.current = clock.current.addingTimeInterval(45)
|
||||
do {
|
||||
_ = try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "nope")
|
||||
Issue.record("expected packFetchBackoff")
|
||||
} catch let StickerPackService.StickerPackError.packFetchBackoff(retryAfter) {
|
||||
#expect(retryAfter > clock.current)
|
||||
} catch {
|
||||
Issue.record("expected packFetchBackoff, got \(error)")
|
||||
}
|
||||
#expect(calls.value == 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("invalid coordinates are rejected before any relay query")
|
||||
func invalidCoordinateRejectedBeforeQuery() async {
|
||||
let clock = MutableClock()
|
||||
let calls = Counter()
|
||||
let service = makeService(clock: clock) { _ in
|
||||
calls.value += 1
|
||||
return []
|
||||
}
|
||||
|
||||
await #expect(throws: StickerPackService.StickerPackError.invalidCoordinate) {
|
||||
try await service.fetchPack(authorPubkeyHex: "not-hex", identifier: "nope")
|
||||
}
|
||||
// A coordinate the wire codec could never express is refused up front.
|
||||
await #expect(throws: StickerPackService.StickerPackError.invalidCoordinate) {
|
||||
try await service.fetchPack(authorPubkeyHex: pubkey, identifier: "has spaces")
|
||||
}
|
||||
#expect(calls.value == 0)
|
||||
}
|
||||
}
|
||||
320
bitchatTests/Services/StickerPackValidationTests.swift
Normal file
320
bitchatTests/Services/StickerPackValidationTests.swift
Normal file
@ -0,0 +1,320 @@
|
||||
//
|
||||
// StickerPackValidationTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Pure validation tests for `StickerPackService.parsePackEvent` — no relay,
|
||||
/// Tor, or network access; `NostrEvent` fixtures are built in code.
|
||||
@Suite("Sticker Pack Validation Tests")
|
||||
struct StickerPackValidationTests {
|
||||
|
||||
private let pubkey = String(repeating: "a", count: 64)
|
||||
private let hash1 = String(repeating: "1", count: 64)
|
||||
private let hash2 = String(repeating: "2", count: 64)
|
||||
|
||||
// MARK: - Fixture builders
|
||||
|
||||
private func makeEvent(
|
||||
kind: Int = 30031,
|
||||
pubkey: String? = nil,
|
||||
createdAt: Int = 1_700_000_000,
|
||||
tags: [[String]]
|
||||
) -> NostrEvent {
|
||||
// Raw-kind init (internal, @testable): bypasses the inbound relay
|
||||
// tag-count cap so >64-tag packs can be exercised for unit tests.
|
||||
NostrEvent(
|
||||
pubkey: pubkey ?? self.pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
tags: tags,
|
||||
content: ""
|
||||
)
|
||||
}
|
||||
|
||||
private func stickerTag(
|
||||
shortcode: String = "party_blob",
|
||||
hash: String? = nil,
|
||||
mime: String = "image/webp",
|
||||
dim: String? = "128x128",
|
||||
url: String? = nil
|
||||
) -> [String] {
|
||||
let h = hash ?? hash1
|
||||
var tag = ["sticker", shortcode, url ?? "https://cdn.example.com/stickers/\(h).webp", h, mime]
|
||||
if let dim { tag.append(dim) }
|
||||
return tag
|
||||
}
|
||||
|
||||
private func validTags(stickers: [[String]]? = nil) -> [[String]] {
|
||||
[
|
||||
["d", "cool-pack"],
|
||||
["title", "Cool Pack"],
|
||||
["pack_format", "sonar-sticker-pack-v1"]
|
||||
] + (stickers ?? [stickerTag()])
|
||||
}
|
||||
|
||||
// MARK: - Valid pack
|
||||
|
||||
@Test("spec-compliant pack event parses with all fields")
|
||||
func validPackParses() {
|
||||
let event = makeEvent(tags: [
|
||||
["d", "cool-pack"],
|
||||
["title", "Cool Pack"],
|
||||
["description", "A very cool pack"],
|
||||
["image", "https://cdn.example.com/cover.png"],
|
||||
["pack_format", "sonar-sticker-pack-v1"],
|
||||
["sticker", "party_blob", "https://cdn.example.com/stickers/\(hash1).webp",
|
||||
hash1, "image/webp", "128x128", "Party blob", "🎉"]
|
||||
])
|
||||
|
||||
let pack = StickerPackService.parsePackEvent(event)
|
||||
#expect(pack != nil)
|
||||
#expect(pack?.coordinate == "30031:\(pubkey):cool-pack")
|
||||
#expect(pack?.authorPubkey == pubkey)
|
||||
#expect(pack?.identifier == "cool-pack")
|
||||
#expect(pack?.title == "Cool Pack")
|
||||
#expect(pack?.description == "A very cool pack")
|
||||
#expect(pack?.coverURL?.absoluteString == "https://cdn.example.com/cover.png")
|
||||
#expect(pack?.stickers.count == 1)
|
||||
let sticker = pack?.stickers.first
|
||||
#expect(sticker?.shortcode == "party_blob")
|
||||
#expect(sticker?.sha256 == hash1)
|
||||
#expect(sticker?.mime == "image/webp")
|
||||
#expect(sticker?.dim == StickerDimensions(width: 128, height: 128))
|
||||
#expect(sticker?.alt == "Party blob")
|
||||
#expect(sticker?.emoji == "🎉")
|
||||
#expect(pack?.createdAt == Date(timeIntervalSince1970: 1_700_000_000))
|
||||
}
|
||||
|
||||
@Test("optional dim/alt/emoji may be absent")
|
||||
func optionalFieldsMayBeAbsent() {
|
||||
let event = makeEvent(tags: validTags(stickers: [
|
||||
["sticker", "blob", "https://cdn.example.com/\(hash1).png", hash1, "image/png"]
|
||||
]))
|
||||
let pack = StickerPackService.parsePackEvent(event)
|
||||
#expect(pack?.stickers.first?.dim == nil)
|
||||
#expect(pack?.stickers.first?.alt == nil)
|
||||
#expect(pack?.stickers.first?.emoji == nil)
|
||||
}
|
||||
|
||||
// MARK: - Envelope rules
|
||||
|
||||
@Test("wrong kind is rejected", arguments: [30032, 1, 10031, 30311])
|
||||
func wrongKindRejected(kind: Int) {
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(kind: kind, tags: validTags())) == nil)
|
||||
}
|
||||
|
||||
@Test("missing pack_format tag is rejected")
|
||||
func missingPackFormatRejected() {
|
||||
let tags = validTags().filter { $0.first != "pack_format" }
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("wrong pack_format value is rejected")
|
||||
func wrongPackFormatRejected() {
|
||||
let tags = validTags().map { $0.first == "pack_format" ? ["pack_format", "other-v1"] : $0 }
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("missing or invalid d tag is rejected", arguments: [nil, "", "has spaces", String(repeating: "x", count: 300)])
|
||||
func invalidDTagRejected(identifier: String?) {
|
||||
var tags = validTags().filter { $0.first != "d" }
|
||||
if let identifier { tags.append(["d", identifier]) }
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("missing or blank title is rejected", arguments: [nil, "", " "])
|
||||
func invalidTitleRejected(title: String?) {
|
||||
var tags = validTags().filter { $0.first != "title" }
|
||||
if let title { tags.append(["title", title]) }
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("malformed pubkey is rejected")
|
||||
func invalidPubkeyRejected() {
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(pubkey: "nothex", tags: validTags())) == nil)
|
||||
#expect(StickerPackService.parsePackEvent(
|
||||
makeEvent(pubkey: String(repeating: "A", count: 64), tags: validTags())
|
||||
) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Sticker tag rules
|
||||
|
||||
@Test("pack with zero stickers is rejected")
|
||||
func noStickersRejected() {
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: validTags(stickers: []))) == nil)
|
||||
}
|
||||
|
||||
@Test("non-HTTPS sticker URL is rejected", arguments: [
|
||||
"http://cdn.example.com/x.webp",
|
||||
"ftp://cdn.example.com/x.webp",
|
||||
"not-a-url",
|
||||
"https://user:pass@cdn.example.com/x.webp"
|
||||
])
|
||||
func nonHTTPSRejected(url: String) {
|
||||
let tags = validTags(stickers: [stickerTag(url: url)])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("sticker URL whose path lacks the sha256 is rejected")
|
||||
func urlMissingHashRejected() {
|
||||
let tags = validTags(stickers: [
|
||||
stickerTag(url: "https://cdn.example.com/stickers/other.webp")
|
||||
])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("disallowed MIME is rejected", arguments: ["image/jpeg", "image/svg+xml", "video/mp4", "application/octet-stream"])
|
||||
func badMimeRejected(mime: String) {
|
||||
let tags = validTags(stickers: [stickerTag(mime: mime)])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("all allowed MIMEs parse", arguments: ["image/webp", "image/png", "image/apng", "image/gif"])
|
||||
func allowedMimesParse(mime: String) {
|
||||
let tags = validTags(stickers: [stickerTag(mime: mime)])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags))?.stickers.first?.mime == mime)
|
||||
}
|
||||
|
||||
@Test("malformed sha256 is rejected", arguments: [
|
||||
"xyz", String(repeating: "1", count: 63), String(repeating: "F", count: 64)
|
||||
])
|
||||
func badSHA256Rejected(hash: String) {
|
||||
let tags = validTags(stickers: [
|
||||
["sticker", "blob", "https://cdn.example.com/\(hash).webp", hash, "image/webp", "10x10"]
|
||||
])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
// Shortcode rules come from the wire codec (`StickerRef.isValidShortcode`):
|
||||
// `[A-Za-z0-9_]{1,64}` — uppercase is valid, dash is not.
|
||||
@Test("invalid shortcode is rejected", arguments: ["", "has space", "emoji🎉", "so-wave", String(repeating: "a", count: 65)])
|
||||
func badShortcodeRejected(shortcode: String) {
|
||||
let tags = validTags(stickers: [stickerTag(shortcode: shortcode)])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("codec-valid shortcode parses (pack and wire rules cannot drift)")
|
||||
func codecValidShortcodeParses() {
|
||||
let tags = validTags(stickers: [stickerTag(shortcode: "Wave_09")])
|
||||
let pack = StickerPackService.parsePackEvent(makeEvent(tags: tags))
|
||||
#expect(pack?.stickers.first?.shortcode == "Wave_09")
|
||||
// …and the same shortcode is encodable into a wire reference.
|
||||
#expect(StickerRef.isValidShortcode("Wave_09"))
|
||||
}
|
||||
|
||||
@Test("duplicate shortcodes are rejected")
|
||||
func duplicateShortcodeRejected() {
|
||||
let tags = validTags(stickers: [
|
||||
stickerTag(shortcode: "dup", hash: hash1),
|
||||
stickerTag(shortcode: "dup", hash: hash2)
|
||||
])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("duplicate hashes are rejected")
|
||||
func duplicateHashRejected() {
|
||||
let tags = validTags(stickers: [
|
||||
stickerTag(shortcode: "one", hash: hash1),
|
||||
stickerTag(shortcode: "two", hash: hash1)
|
||||
])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("packs with more than 200 stickers are rejected")
|
||||
func tooManyStickersRejected() {
|
||||
let stickers = (0..<201).map { i -> [String] in
|
||||
let hash = String(format: "%064d", i + 1) // decimal digits are valid lowercase hex
|
||||
return ["sticker", "s\(i)", "https://cdn.example.com/\(hash).webp", hash, "image/webp", "10x10"]
|
||||
}
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: validTags(stickers: stickers))) == nil)
|
||||
}
|
||||
|
||||
@Test("200 stickers exactly parses")
|
||||
func exactlyMaxStickersParses() {
|
||||
let stickers = (0..<200).map { i -> [String] in
|
||||
let hash = String(format: "%064d", i + 1)
|
||||
return ["sticker", "s\(i)", "https://cdn.example.com/\(hash).webp", hash, "image/webp", "10x10"]
|
||||
}
|
||||
let pack = StickerPackService.parsePackEvent(makeEvent(tags: validTags(stickers: stickers)))
|
||||
#expect(pack?.stickers.count == 200)
|
||||
}
|
||||
|
||||
@Test("invalid dim is rejected", arguments: ["0x10", "10x0", "5000x10", "10x5000", "abc", "10x20x30", "-5x10", "10X20", "10 x20"])
|
||||
func badDimRejected(dim: String) {
|
||||
let tags = validTags(stickers: [stickerTag(dim: dim)])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
@Test("boundary dims parse", arguments: ["1x1", "4096x4096", "1x4096"])
|
||||
func boundaryDimsParse(dim: String) {
|
||||
let tags = validTags(stickers: [stickerTag(dim: dim)])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags))?.stickers.first?.dim != nil)
|
||||
}
|
||||
|
||||
@Test("truncated sticker tag is rejected")
|
||||
func truncatedStickerTagRejected() {
|
||||
let tags = validTags(stickers: [
|
||||
["sticker", "blob", "https://cdn.example.com/\(hash1).webp", hash1] // missing mime
|
||||
])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Cover image
|
||||
|
||||
@Test("non-HTTPS cover image is rejected")
|
||||
func nonHTTPSCoverRejected() {
|
||||
var tags = validTags()
|
||||
tags.append(["image", "http://cdn.example.com/cover.png"])
|
||||
#expect(StickerPackService.parsePackEvent(makeEvent(tags: tags)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Ref validation (pack-edit attack)
|
||||
|
||||
@Test("validateStickerRef requires both shortcode and pinned hash in the current pack")
|
||||
@MainActor
|
||||
func validateStickerRefAgainstPack() {
|
||||
let event = makeEvent(tags: validTags(stickers: [
|
||||
stickerTag(shortcode: "party_blob", hash: hash1)
|
||||
]))
|
||||
guard let pack = StickerPackService.parsePackEvent(event) else {
|
||||
Issue.record("fixture pack failed to parse")
|
||||
return
|
||||
}
|
||||
let service = StickerPackService(
|
||||
cache: StickerCache(baseDirectory: FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("sticker-svc-tests-\(UUID().uuidString)", isDirectory: true)),
|
||||
relayQuery: { _ in [] },
|
||||
downloader: { _ in throw URLError(.networkConnectionLost) },
|
||||
networkActivationAllowed: { false },
|
||||
metadataURL: nil
|
||||
)
|
||||
|
||||
// Happy path: coordinate + shortcode + pinned hash all match.
|
||||
#expect(service.validateStickerRef(
|
||||
packCoordinate: pack.coordinate, shortcode: "party_blob",
|
||||
plaintextSha256: hash1, against: pack
|
||||
))
|
||||
// Pack edited: shortcode still present but hash changed → untrusted.
|
||||
#expect(!service.validateStickerRef(
|
||||
packCoordinate: pack.coordinate, shortcode: "party_blob",
|
||||
plaintextSha256: hash2, against: pack
|
||||
))
|
||||
// Shortcode removed from the pack → untrusted.
|
||||
#expect(!service.validateStickerRef(
|
||||
packCoordinate: pack.coordinate, shortcode: "gone",
|
||||
plaintextSha256: hash1, against: pack
|
||||
))
|
||||
// Different pack entirely → untrusted.
|
||||
#expect(!service.validateStickerRef(
|
||||
packCoordinate: "30031:\(String(repeating: "b", count: 64)):cool-pack",
|
||||
shortcode: "party_blob", plaintextSha256: hash1, against: pack
|
||||
))
|
||||
}
|
||||
}
|
||||
147
docs/SONAR-STICKERS.md
Normal file
147
docs/SONAR-STICKERS.md
Normal file
@ -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␟<pack-coordinate>␟<shortcode>␟<plaintext-sha256>
|
||||
```
|
||||
|
||||
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>`; 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:<pubkey>:<identifier>` 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.
|
||||
Loading…
x
Reference in New Issue
Block a user