feat: Sonar sticker pack fetch, verified cache, install store (PR B of #1517 split)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vincenzo Palazzo 2026-07-29 19:24:53 +02:00
parent 20c08bd860
commit ecd4da139c
8 changed files with 1754 additions and 2 deletions

View File

@ -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 }

View 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()
}
}

View 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)
}
}

View 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
}
}
}

View File

@ -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

View 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)
}
}

View 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)
}
}

View 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
))
}
}