mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## Context Follow-up to [#8973](https://github.com/block/buzz/pull/8973), from Codex's [review](https://github.com/block/buzz/pull/8973#discussion_r4458876285) of its final head. The push dedupe cutoff subtracts the 15 minute reorder window from the newest shown time. A signed event can carry any `created_at`. One within 900 seconds of `Int.min` made that subtraction overflow and trap, crashing the Notification Service Extension. The claim was never saved, so every later push crashed the same way while the relay kept returning that event. The relay's own drift check normally rejects such timestamps, but the extension shouldn't depend on that. ## Change The cutoff now uses saturating subtraction (`BuzzNotificationCache.presentationCutoff(after:)`), in both the claim check and retention pruning. ## Validation - New test `testExtremeTimestampsDoNotCrashTheClaim` claims messages dated `Int.min`, `Int.min + 1`, now, and `Int.max`. It checks that each behaves like any other message and nothing crashes. With the plain subtraction restored, it crashes with signal 5 (overflow trap). - Full BuzzPushKit suite: 108 XCTest and 66 Swift Testing tests passed. - Automated app test: not run. This is a two-line arithmetic change inside the extension's cache. The live APNs run on [#8973](https://github.com/block/buzz/pull/8973) covers the flow. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
464 lines
20 KiB
Swift
464 lines
20 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
|
|
/// Account and relay identity for one local sign-in's notification cache.
|
|
public struct BuzzNotificationCacheScope: Codable, Equatable, Sendable {
|
|
public let communityID: String
|
|
public let relayOrigin: String
|
|
public let account: String
|
|
public let sessionID: String
|
|
|
|
public init?(community: PushLeaseCommunity) {
|
|
guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl),
|
|
let account = community.pubkey, !account.isEmpty,
|
|
community.notificationSessionID?.isEmpty != true
|
|
else { return nil }
|
|
communityID = community.id
|
|
relayOrigin = origin
|
|
self.account = account.lowercased()
|
|
// Before the first launch after upgrade, the account snapshot has no session
|
|
// field. Its scope is still isolated by account, relay and community. The new
|
|
// app replaces this marker with a UUID on its first sync and every re-add.
|
|
sessionID = community.notificationSessionID ?? "legacy-before-first-sync-v1"
|
|
}
|
|
|
|
/// Confirms the loaded credential belongs to this scope's account.
|
|
public func matchesAccountKey(_ privateKey: String?) -> Bool {
|
|
guard let privateKey, let bytes = VerifiedNostrEvent.hexBytes(privateKey), bytes.count == 32,
|
|
let pubkey = try? NostrSchnorr.publicKey(secret: bytes)
|
|
else { return false }
|
|
return VerifiedNostrEvent.hex(pubkey) == account
|
|
}
|
|
|
|
/// A filename-safe identity that contains no credentials.
|
|
public var directoryName: String {
|
|
let fields = [communityID, relayOrigin, account, sessionID]
|
|
let bytes = fields.flatMap { Array("\($0.utf8.count):\($0)".utf8) }
|
|
return VerifiedNostrEvent.hex(SHA256.hash(data: bytes))
|
|
}
|
|
}
|
|
|
|
/// Verified metadata plus the time it was last checked with the relay.
|
|
public struct BuzzNotificationMetadata: Codable, Equatable, Sendable {
|
|
public let event: VerifiedNostrEvent
|
|
public let checkedAt: Date
|
|
|
|
/// One shared freshness threshold for profile and channel presentation metadata.
|
|
public static let freshness: TimeInterval = 60 * 60
|
|
|
|
public func isFresh(at date: Date) -> Bool {
|
|
let age = date.timeIntervalSince(checkedAt)
|
|
return age >= 0 && age < Self.freshness
|
|
}
|
|
}
|
|
|
|
/// Last successfully decoded image for a sender, retained during replacement fetches.
|
|
public struct BuzzNotificationAvatar: Codable, Equatable, Sendable {
|
|
public let pubkey: String
|
|
public let source: String
|
|
public let data: Data
|
|
public let savedAt: Date
|
|
}
|
|
|
|
/// Failures callers must report; revoked accounts must never use stale cache results.
|
|
public enum BuzzNotificationCacheError: Error {
|
|
case revoked, invalidRecord, oversizedRecord, corruptStore
|
|
}
|
|
|
|
/// Extension-owned metadata storage. Each operation reads current disk state under
|
|
/// a process lock and publishes one complete snapshot with an atomic replacement.
|
|
/// Image HTTP response storage remains URLCache's responsibility.
|
|
public final class BuzzNotificationCache: @unchecked Sendable {
|
|
public static let maximumBytes = 8 * 1024 * 1024
|
|
public static let maximumAvatarBytes = 256 * 1024
|
|
private static let processLock = NSLock()
|
|
private let root: URL
|
|
private let now: () -> Date
|
|
private let isCurrent: (BuzzNotificationCacheScope) -> Bool
|
|
|
|
private struct State: Codable {
|
|
var version = 1
|
|
var metadata: [BuzzNotificationMetadata] = []
|
|
var avatars: [BuzzNotificationAvatar] = []
|
|
var avatarRequests: [String: String]?
|
|
/// Messages already shown in a notification, newest first. Optional so stores
|
|
/// written before it existed still decode.
|
|
var presented: [PresentedMessage]?
|
|
}
|
|
|
|
private struct PresentedMessage: Codable {
|
|
let id: String
|
|
/// The event's own `created_at`. Retention follows it, so an entry stays while the
|
|
/// relay still ranks the event among the newest results.
|
|
let createdAt: Int
|
|
/// `createdAt` capped at the time it was shown, so a future-dated event can't hold
|
|
/// back later messages.
|
|
let watermark: Int
|
|
}
|
|
|
|
/// How far before the newest shown message a message may be and still be shown.
|
|
/// Pushes can resolve out of order, and the relay accepts a `created_at` up to 15
|
|
/// minutes off (`MAX_TIMESTAMP_DRIFT_SECS` in the relay's ingest handler), so a new
|
|
/// message can carry an earlier time than one already shown. Anything older is
|
|
/// history, not the message a push is for.
|
|
public static let presentationReorderWindow = 15 * 60
|
|
|
|
/// The oldest time still within ``presentationReorderWindow`` of `newest`. Saturates,
|
|
/// because a signed event can carry any `created_at` and an overflow would crash the
|
|
/// extension on every push.
|
|
static func presentationCutoff(after newest: Int) -> Int {
|
|
let (cutoff, overflow) = newest.subtractingReportingOverflow(presentationReorderWindow)
|
|
return overflow ? .min : cutoff
|
|
}
|
|
/// Retention keeps up to this many entries by `watermark` and as many by `createdAt`,
|
|
/// so neither future-dated entries nor a burst of recent ones can evict the other.
|
|
static let maximumPresentedMessages = 256
|
|
|
|
public init(
|
|
directory: URL,
|
|
now: @escaping () -> Date = Date.init,
|
|
isCurrent: @escaping (BuzzNotificationCacheScope) -> Bool
|
|
) {
|
|
root = directory
|
|
self.now = now
|
|
self.isCurrent = isCurrent
|
|
}
|
|
|
|
/// Returns a complete cached record, including stale records for presentation fallback.
|
|
public func metadata(
|
|
scope: BuzzNotificationCacheScope, kind: Int, author: String, identifier: String? = nil
|
|
) throws -> BuzzNotificationMetadata? {
|
|
try access(scope) { state in
|
|
state.metadata.first {
|
|
$0.event.kind == kind && $0.event.pubkey.lowercased() == author.lowercased()
|
|
&& Self.identifier($0.event) == identifier
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Saves verified replaceable metadata without allowing an older response to win.
|
|
public func save(event: VerifiedNostrEvent, scope: BuzzNotificationCacheScope) throws {
|
|
try save(events: [event], scope: scope)
|
|
}
|
|
|
|
/// Commits records returned together in a single atomic snapshot.
|
|
func save(events: [VerifiedNostrEvent], scope: BuzzNotificationCacheScope) throws {
|
|
try save(events: events, scope: scope, channelRefresh: false)
|
|
}
|
|
|
|
/// Keeps a partial channel response without combining it with an old counterpart.
|
|
func saveChannel(
|
|
channel: VerifiedNostrEvent?, membership: VerifiedNostrEvent?, scope: BuzzNotificationCacheScope
|
|
) throws {
|
|
try save(events: [channel, membership].compactMap { $0 }, scope: scope, channelRefresh: true)
|
|
}
|
|
|
|
private func save(
|
|
events: [VerifiedNostrEvent], scope: BuzzNotificationCacheScope, channelRefresh: Bool
|
|
) throws {
|
|
guard !events.isEmpty else { return }
|
|
for event in events {
|
|
guard [0, 39_000, 39_002].contains(event.kind), event.hasValidIDAndSignature(),
|
|
event.content.utf8.count <= 256 * 1024
|
|
else { throw BuzzNotificationCacheError.invalidRecord }
|
|
if event.kind == 0 {
|
|
guard let data = event.content.data(using: .utf8),
|
|
(try? JSONSerialization.jsonObject(with: data)) is [String: Any]
|
|
else { throw BuzzNotificationCacheError.invalidRecord }
|
|
}
|
|
}
|
|
try access(scope, write: true) { state in
|
|
if channelRefresh {
|
|
// Reject the whole refresh if any returned record predates its cached
|
|
// version. Accepting only the other record would mix refreshes again.
|
|
for event in events {
|
|
let existing = state.metadata.first {
|
|
$0.event.kind == event.kind && $0.event.pubkey == event.pubkey
|
|
&& Self.identifier($0.event) == Self.identifier(event)
|
|
}?.event
|
|
guard BuzzPushPresentationCacheStore.shouldReplace(
|
|
existingCreatedAt: existing?.createdAt, existingID: existing?.id,
|
|
candidateCreatedAt: event.createdAt, candidateID: event.id
|
|
) else { return }
|
|
}
|
|
}
|
|
for event in events {
|
|
let index = state.metadata.firstIndex {
|
|
$0.event.kind == event.kind && $0.event.pubkey == event.pubkey
|
|
&& Self.identifier($0.event) == Self.identifier(event)
|
|
}
|
|
let existing = index.map { state.metadata[$0].event }
|
|
guard
|
|
BuzzPushPresentationCacheStore.shouldReplace(
|
|
existingCreatedAt: existing?.createdAt, existingID: existing?.id,
|
|
candidateCreatedAt: event.createdAt, candidateID: event.id
|
|
)
|
|
else { continue }
|
|
let record = BuzzNotificationMetadata(event: event, checkedAt: now())
|
|
if let index { state.metadata[index] = record } else { state.metadata.append(record) }
|
|
if channelRefresh, events.count == 1, [39000, 39002].contains(event.kind) {
|
|
let counterpartKind = event.kind == 39000 ? 39002 : 39000
|
|
state.metadata.removeAll {
|
|
$0.event.kind == counterpartKind && $0.event.pubkey == event.pubkey
|
|
&& Self.identifier($0.event) == Self.identifier(event)
|
|
}
|
|
}
|
|
if event.kind == 0, Self.removesPicture(event) {
|
|
state.avatars.removeAll { $0.pubkey == event.pubkey }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Presentation {
|
|
var profile: BuzzNotificationMetadata?
|
|
var avatar: BuzzNotificationAvatar?
|
|
var channel: BuzzNotificationMetadata?
|
|
var membership: BuzzNotificationMetadata?
|
|
}
|
|
|
|
/// Reads related fields from one snapshot, so a concurrent update cannot mix generations.
|
|
func presentation(
|
|
scope: BuzzNotificationCacheScope, sender: String,
|
|
channelID: String?, authority: String?
|
|
) throws -> Presentation {
|
|
try access(scope) { state in
|
|
let records = state.metadata
|
|
return Presentation(
|
|
profile: records.first { $0.event.kind == 0 && $0.event.pubkey == sender },
|
|
avatar: state.avatars.first { $0.pubkey == sender },
|
|
channel: records.first {
|
|
channelID != nil && authority != nil && $0.event.kind == 39000
|
|
&& $0.event.pubkey == authority && Self.identifier($0.event) == channelID
|
|
},
|
|
membership: records.first {
|
|
channelID != nil && authority != nil && $0.event.kind == 39002
|
|
&& $0.event.pubkey == authority && Self.identifier($0.event) == channelID
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Returns the previous image even when the current profile references a replacement.
|
|
public func avatar(scope: BuzzNotificationCacheScope, pubkey: String) throws
|
|
-> BuzzNotificationAvatar?
|
|
{
|
|
try access(scope) { state in state.avatars.first { $0.pubkey == pubkey } }
|
|
}
|
|
|
|
/// Registers the newest image request under the same lock used by all cache writers.
|
|
public func beginAvatarRequest(
|
|
profileID: String, pubkey: String, scope: BuzzNotificationCacheScope
|
|
) throws -> String? {
|
|
try access(scope, write: true) { state in
|
|
guard
|
|
state.metadata.contains(where: {
|
|
$0.event.kind == 0 && $0.event.pubkey == pubkey && $0.event.id == profileID
|
|
})
|
|
else { return nil }
|
|
let requestID = UUID().uuidString
|
|
if state.avatarRequests == nil { state.avatarRequests = [:] }
|
|
state.avatarRequests?[pubkey] = requestID
|
|
return requestID
|
|
}
|
|
}
|
|
|
|
/// Publishes only the latest request while the current profile still identifies its source.
|
|
public func saveAvatar(
|
|
_ data: Data, source: String, profileID: String, requestID: String, pubkey: String,
|
|
scope: BuzzNotificationCacheScope
|
|
) throws {
|
|
guard !data.isEmpty, data.count <= Self.maximumAvatarBytes else {
|
|
throw BuzzNotificationCacheError.oversizedRecord
|
|
}
|
|
try access(scope, write: true) { state in
|
|
guard
|
|
let profile = state.metadata.first(where: {
|
|
$0.event.kind == 0 && $0.event.pubkey == pubkey
|
|
}), profile.event.id == profileID, Self.picture(profile.event) == source,
|
|
state.avatarRequests?[pubkey] == requestID
|
|
else { return }
|
|
state.avatars.removeAll { $0.pubkey == pubkey }
|
|
state.avatars.append(
|
|
BuzzNotificationAvatar(
|
|
pubkey: pubkey, source: source, data: data, savedAt: now()))
|
|
}
|
|
}
|
|
|
|
/// Claims the first of `candidates`, newest first, that hasn't been shown and isn't
|
|
/// older than the newest message shown in any of `scopes` by more than
|
|
/// ``presentationReorderWindow``, and returns its index. A push carries no event
|
|
/// reference, so without this every push whose own event isn't the newest match would
|
|
/// repeat the same message. One lock covers the whole selection, so concurrent pushes
|
|
/// can't claim against a stale view, and only a claim writes. A revoked scope is
|
|
/// skipped. Broken storage must not hide messages, so a candidate whose store can't
|
|
/// be read or written is returned without being recorded.
|
|
public func claimFirstUnpresented(
|
|
_ candidates: [(event: VerifiedNostrEvent, scope: BuzzNotificationCacheScope)],
|
|
scopes: [BuzzNotificationCacheScope]
|
|
) throws -> Int? {
|
|
try locked {
|
|
var shown: [String: Result<[PresentedMessage], Error>] = [:]
|
|
func presented(_ scope: BuzzNotificationCacheScope) -> Result<[PresentedMessage], Error> {
|
|
if let cached = shown[scope.directoryName] { return cached }
|
|
let result = Result { try unlockedAccess(scope) { $0.presented ?? [] } }
|
|
shown[scope.directoryName] = result
|
|
return result
|
|
}
|
|
let newestShown = (scopes + candidates.map(\.scope))
|
|
.compactMap { try? presented($0).get().map(\.watermark).max() }.max()
|
|
let now = Int(now().timeIntervalSince1970)
|
|
for (index, candidate) in candidates.enumerated() {
|
|
let watermark = min(candidate.event.createdAt, now)
|
|
switch presented(candidate.scope) {
|
|
case .failure(BuzzNotificationCacheError.revoked):
|
|
continue
|
|
case .failure(let error):
|
|
NSLog("Notification dedupe unavailable: %@", String(describing: error))
|
|
return index
|
|
case .success(let entries):
|
|
guard !entries.contains(where: { $0.id == candidate.event.id }) else { continue }
|
|
if let newestShown, watermark < Self.presentationCutoff(after: newestShown) {
|
|
continue
|
|
}
|
|
}
|
|
do {
|
|
try unlockedAccess(candidate.scope, write: true) { state in
|
|
state.presented =
|
|
[PresentedMessage(
|
|
id: candidate.event.id, createdAt: candidate.event.createdAt,
|
|
watermark: watermark)] + (state.presented ?? [])
|
|
}
|
|
} catch BuzzNotificationCacheError.revoked {
|
|
continue
|
|
} catch {
|
|
NSLog("Notification dedupe not recorded: %@", String(describing: error))
|
|
}
|
|
return index
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Deletes abandoned sign-ins on the extension's next execution.
|
|
public func removeRevokedScopes(keeping scopes: [BuzzNotificationCacheScope]) throws {
|
|
try locked {
|
|
let retained = Set(scopes.map(\.directoryName))
|
|
for url in try FileManager.default.contentsOfDirectory(
|
|
at: root, includingPropertiesForKeys: [.isDirectoryKey]
|
|
) where url.lastPathComponent != "cache.lock" {
|
|
guard !retained.contains(url.lastPathComponent) else { continue }
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Normalized source from a verified profile. Missing or empty picture means removal.
|
|
static func picture(_ event: VerifiedNostrEvent) -> String? {
|
|
guard let data = event.content.data(using: .utf8),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else { return nil }
|
|
return BuzzNotificationAvatarLoader.normalizedSource(object["picture"] as? String)
|
|
}
|
|
|
|
private static func removesPicture(_ event: VerifiedNostrEvent) -> Bool {
|
|
guard let data = event.content.data(using: .utf8),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else { return false }
|
|
guard let value = object["picture"], !(value is NSNull) else { return true }
|
|
guard let value = value as? String else { return false }
|
|
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private static func identifier(_ event: VerifiedNostrEvent) -> String? {
|
|
event.kind == 0 ? nil : event.tags.first { $0.count > 1 && $0[0] == "d" }?[1]
|
|
}
|
|
|
|
private func access<T>(
|
|
_ scope: BuzzNotificationCacheScope, write: Bool = false,
|
|
_ operation: (inout State) throws -> T
|
|
) throws -> T {
|
|
try locked { try unlockedAccess(scope, write: write, operation) }
|
|
}
|
|
|
|
/// ``access(_:write:_:)`` for a caller that already holds the lock.
|
|
private func unlockedAccess<T>(
|
|
_ scope: BuzzNotificationCacheScope, write: Bool = false,
|
|
_ operation: (inout State) throws -> T
|
|
) throws -> T {
|
|
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
|
|
let directory = root.appendingPathComponent(scope.directoryName, isDirectory: true)
|
|
let file = directory.appendingPathComponent("metadata.json")
|
|
var state = State()
|
|
if FileManager.default.fileExists(atPath: file.path) {
|
|
let attributes = try file.resourceValues(forKeys: [.fileSizeKey])
|
|
guard let size = attributes.fileSize, size <= Self.maximumBytes else {
|
|
try FileManager.default.removeItem(at: file)
|
|
throw BuzzNotificationCacheError.corruptStore
|
|
}
|
|
do {
|
|
state = try JSONDecoder().decode(State.self, from: Data(contentsOf: file))
|
|
} catch {
|
|
try FileManager.default.removeItem(at: file)
|
|
throw BuzzNotificationCacheError.corruptStore
|
|
}
|
|
guard state.version == 1 else {
|
|
try FileManager.default.removeItem(at: file)
|
|
throw BuzzNotificationCacheError.corruptStore
|
|
}
|
|
}
|
|
let result = try operation(&state)
|
|
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
|
|
if write {
|
|
state.metadata = Array(state.metadata.sorted { $0.checkedAt > $1.checkedAt }.prefix(512))
|
|
state.avatars = Array(state.avatars.sorted { $0.savedAt > $1.savedAt }.prefix(128))
|
|
let profileAuthors = Set(
|
|
state.metadata.filter { $0.event.kind == 0 }.map { $0.event.pubkey })
|
|
state.avatarRequests = state.avatarRequests?.filter { profileAuthors.contains($0.key) }
|
|
if let presented = state.presented, let newest = presented.map(\.watermark).max() {
|
|
// The age check rejects anything older than the window, so those entries can
|
|
// go. A future-dated entry stays while the relay may still rank it first.
|
|
let cutoff = Self.presentationCutoff(after: newest)
|
|
// Entries are newest claim first, so ties keep the most recent claims.
|
|
let current = Array(presented.filter { $0.createdAt >= cutoff }.enumerated())
|
|
func newestIDs(by key: (PresentedMessage) -> Int) -> [String] {
|
|
current.sorted {
|
|
key($0.element) == key($1.element)
|
|
? $0.offset < $1.offset : key($0.element) > key($1.element)
|
|
}.prefix(Self.maximumPresentedMessages).map(\.element.id)
|
|
}
|
|
let retained = Set(newestIDs(by: \.watermark)).union(newestIDs(by: \.createdAt))
|
|
state.presented = current.map(\.element).filter { retained.contains($0.id) }
|
|
}
|
|
let encoder = JSONEncoder()
|
|
var data = try encoder.encode(state)
|
|
while data.count > Self.maximumBytes {
|
|
if !state.avatars.isEmpty {
|
|
state.avatars.removeLast()
|
|
} else if !state.metadata.isEmpty {
|
|
state.metadata.removeLast()
|
|
} else {
|
|
throw BuzzNotificationCacheError.oversizedRecord
|
|
}
|
|
data = try encoder.encode(state)
|
|
}
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
try data.write(to: file, options: .atomic)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func locked<T>(_ operation: () throws -> T) throws -> T {
|
|
Self.processLock.lock()
|
|
defer { Self.processLock.unlock() }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
let descriptor = open(root.appendingPathComponent("cache.lock").path, O_CREAT | O_RDWR, 0o600)
|
|
guard descriptor >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) }
|
|
defer { close(descriptor) }
|
|
guard flock(descriptor, LOCK_EX) == 0 else {
|
|
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
|
|
}
|
|
defer { flock(descriptor, LOCK_UN) }
|
|
return try operation()
|
|
}
|
|
}
|