diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 9ab821332..05854a63c 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -27,12 +27,14 @@ import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.core.isEphemeral import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.nio.file.FileAlreadyExistsException import java.nio.file.Files @@ -63,6 +65,13 @@ class FsEventStore( * NIP-40 expiration deterministically. Defaults to `TimeUtils.now()`. */ private val clock: () -> Long = { TimeUtils.now() }, + /** + * Optional relay URL the store is acting on behalf of. Used by + * NIP-62 [RequestToVanishEvent.shouldVanishFrom] scoping. When + * `null`, only "ALL_RELAYS" vanish requests cascade — matches + * `SQLiteEventStore`'s relay arg semantics. + */ + private val relay: NormalizedRelayUrl? = null, ) : IEventStore { private val layout = FsLayout(root) private val hasher: TagNameValueHasher @@ -108,6 +117,7 @@ class FsEventStore( slots.install(slot, canonical, event, existingSlot) } if (event is DeletionEvent) processDeletion(event, canonical) + if (event is RequestToVanishEvent) processVanish(event, canonical) return } @@ -129,6 +139,7 @@ class FsEventStore( slots.install(slot, canonical, event, existingSlot) } if (event is DeletionEvent) processDeletion(event, canonical) + if (event is RequestToVanishEvent) processVanish(event, canonical) } catch (t: Throwable) { Files.deleteIfExists(tmp) throw t @@ -163,6 +174,11 @@ class FsEventStore( val cutoff = tombstones.addrTombstoneCutoff(event.kind, event.pubKey, "") if (cutoff != null && event.createdAt <= cutoff) return true } + // NIP-62: any event whose owner has an active vanish request with + // createdAt >= event.createdAt is rejected. Owner is the recipient + // for GiftWrap, matching SQLite's `pubkey_owner_hash`. + val vanishCutoff = tombstones.vanishCutoff(indexer.ownerHash(event)) + if (vanishCutoff != null && event.createdAt <= vanishCutoff) return true return false } @@ -315,6 +331,36 @@ class FsEventStore( return if (canonical.deleteIfExists()) 1 else 0 } + /** + * NIP-62 right-to-vanish processing. When the kind-62 event scopes + * to this store's relay (or "ALL_RELAYS"), install/replace the + * vanish tombstone keyed by owner hash and cascade-delete every + * event from that owner with `createdAt < vanish.createdAt`. Mirrors + * SQLite's `delete_events_on_event_vanish` AFTER-INSERT trigger. + */ + private fun processVanish( + event: RequestToVanishEvent, + canonical: java.nio.file.Path, + ) { + if (!event.shouldVanishFrom(relay)) return + val ownerHash = hasher.hash(event.pubKey) + if (!tombstones.installVanish(ownerHash, event, canonical)) return + + // Cascade: walk idx/owner//, delete every event whose + // ts < vanish.createdAt. The vanish event's own ts equals + // vanish.createdAt so it survives. + val ownerDir = layout.idxOwner.resolve(FsLayout.hashHex(ownerHash)) + if (!Files.isDirectory(ownerDir)) return + val toDelete = ArrayList() + Files.list(ownerDir).use { stream -> + for (entry in stream) { + val parsed = FsLayout.parseEntry(entry.fileName.toString()) ?: continue + if (parsed.first < event.createdAt) toDelete.add(parsed.second) + } + } + toDelete.forEach { delete(it) } + } + /** * Sweep expired events. Walks `idx/expires_at/`, parses `-` * filenames, and deletes any entry whose `exp < now`. Matches SQLite's diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt index 6cef7d8b7..7353e74e6 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsLayout.kt @@ -51,6 +51,7 @@ internal class FsLayout( val tombstones: Path = root.resolve(TOMBSTONES_DIR) val tombstonesId: Path = tombstones.resolve(TOMB_ID) val tombstonesAddr: Path = tombstones.resolve(TOMB_ADDR) + val tombstonesVanish: Path = tombstones.resolve(TOMB_VANISH) fun canonical(id: HexKey): Path { require(id.length >= 4) { "event id must be at least 4 hex chars, got '$id'" } @@ -127,6 +128,9 @@ internal class FsLayout( dTag: String, ): Path = tombstonesAddr.resolve(kind.toString()).resolve(pubkey).resolve("${sha256Hex(dTag)}$JSON_EXT") + /** NIP-62 vanish tombstone keyed by owner hash (matches `pubkey_owner_hash`). */ + fun vanishTombstonePath(ownerHash: Long): Path = tombstonesVanish.resolve("${hashHex(ownerHash)}$JSON_EXT") + fun ensureSkeleton() { Files.createDirectories(events) Files.createDirectories(staging) @@ -139,6 +143,7 @@ internal class FsLayout( Files.createDirectories(addressable) Files.createDirectories(tombstonesId) Files.createDirectories(tombstonesAddr) + Files.createDirectories(tombstonesVanish) } /** @@ -174,6 +179,7 @@ internal class FsLayout( const val TOMBSTONES_DIR = "tombstones" const val TOMB_ID = "id" const val TOMB_ADDR = "addr" + const val TOMB_VANISH = "vanish" const val SEED_FILE = ".seed" const val JSON_EXT = ".json" diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt index 932737aa9..25ca4171b 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsTombstones.kt @@ -124,4 +124,52 @@ internal class FsTombstones( ) { Files.deleteIfExists(layout.addrTombstonePath(kind, pubkey, dTag)) } + + // ------------------------------------------------------------------ + // NIP-62 vanish tombstones (one per owner; latest wins) + // ------------------------------------------------------------------ + + /** Vanish cutoff for the given owner, or null if no tombstone. */ + fun vanishCutoff(ownerHash: Long): Long? { + val path = layout.vanishTombstonePath(ownerHash) + if (!path.exists()) return null + return try { + Event.fromJson(path.readText()).createdAt + } catch (_: java.nio.file.NoSuchFileException) { + null + } catch (_: Exception) { + null + } + } + + /** + * Install a vanish tombstone, atomically replacing any existing one + * with a strictly older `createdAt`. Returns true when this call + * actually installed a new (or stronger) tombstone — the caller + * should run the cascade only when this is true. + */ + fun installVanish( + ownerHash: Long, + vanishEvent: Event, + canonical: Path, + ): Boolean { + val path = layout.vanishTombstonePath(ownerHash) + val existing = vanishCutoff(ownerHash) + if (existing != null && existing >= vanishEvent.createdAt) return false + + Files.createDirectories(path.parent) + val tmp = path.resolveSibling("${path.fileName}.tmp.${UUID.randomUUID()}") + try { + Files.createLink(tmp, canonical) + Files.move(tmp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (t: Throwable) { + Files.deleteIfExists(tmp) + throw t + } + return true + } + + fun clearVanish(ownerHash: Long) { + Files.deleteIfExists(layout.vanishTombstonePath(ownerHash)) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt new file mode 100644 index 000000000..0e824a865 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsVanishTest.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.fs + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FsVanishTest { + private val signer = NostrSignerSync() + private val otherSigner = NostrSignerSync() + private val storeRelay = "wss://quartz.local".normalizeRelayUrl() + private lateinit var root: Path + private lateinit var store: FsEventStore + + @BeforeTest + fun setup() { + Secp256k1Instance + root = Files.createTempDirectory("fs-van-") + store = FsEventStore(root, relay = storeRelay) + } + + @AfterTest + fun tearDown() { + store.close() + if (root.exists()) { + Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } } + } + } + + private fun note( + body: String, + ts: Long, + s: NostrSignerSync = signer, + ) = s.sign(TextNoteEvent.build(body, createdAt = ts)) + + private fun vanish( + ts: Long, + relayUrl: String = storeRelay.url, + s: NostrSignerSync = signer, + ) = s.sign(RequestToVanishEvent.build(relayUrl.normalizeRelayUrl(), createdAt = ts)) + + private fun vanishEverywhere( + ts: Long, + s: NostrSignerSync = signer, + ) = s.sign(RequestToVanishEvent.buildVanishFromEverywhere(createdAt = ts)) + + // ------------------------------------------------------------------ + // Cascade + // ------------------------------------------------------------------ + + @Test + fun `vanish for this relay cascades older events from the same author`() { + val n1 = note("a", 10) + val n2 = note("b", 20) + val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict < + store.insert(n1) + store.insert(n2) + store.insert(n3) + + val v = vanish(ts = 30) + store.insert(v) + + assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted") + assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted") + assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives") + assertTrue(store.hasCanonical(v.id)) + } + + @Test + fun `vanish for a different relay does NOT cascade`() { + val n = note("x", 10) + store.insert(n) + + val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example") + store.insert(v) + + assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade") + // The kind-62 event itself is still persisted (it's just a normal event). + assertTrue(store.hasCanonical(v.id)) + // No vanish tombstone installed. + val tombDir = root.resolve("tombstones/vanish") + if (tombDir.exists()) { + assertEquals(0, Files.list(tombDir).use { it.toList() }.size) + } + } + + @Test + fun `vanishFromEverywhere always cascades regardless of relay`() { + val n = note("x", 10) + store.insert(n) + + val v = vanishEverywhere(ts = 20) + store.insert(v) + + assertFalse(store.hasCanonical(n.id)) + } + + // ------------------------------------------------------------------ + // Block re-insert + // ------------------------------------------------------------------ + + @Test + fun `events older than vanish are blocked from re-insertion`() { + val n = note("a", 10) + store.insert(n) + + val v = vanish(ts = 50) + store.insert(v) + + // Re-insert blocked. + store.insert(n) + assertFalse(store.hasCanonical(n.id)) + + // A brand-new older event by the same author also blocked. + val older = note("older", 5) + store.insert(older) + assertFalse(store.hasCanonical(older.id)) + } + + @Test + fun `events at vanish ts are blocked, parity with SQLite`() { + val v = vanish(ts = 50) + store.insert(v) + + val equal = note("equal", 50) + store.insert(equal) + assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked") + } + + @Test + fun `events newer than vanish still pass`() { + val v = vanish(ts = 50) + store.insert(v) + + val newer = note("newer", 100) + store.insert(newer) + assertTrue(store.hasCanonical(newer.id)) + } + + @Test + fun `another author is unaffected by my vanish`() { + val mine = note("mine", 10) + store.insert(mine) + val theirs = note("theirs", 5, s = otherSigner) + store.insert(theirs) + + val v = vanish(ts = 50) + store.insert(v) + + assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted") + assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected") + } + + // ------------------------------------------------------------------ + // Multiple vanish requests — strongest cutoff wins + // ------------------------------------------------------------------ + + @Test + fun `later vanish raises the cutoff`() { + val n100 = note("at-100", 100) + store.insert(n100) + store.insert(vanish(ts = 50)) + // n100 still around because 100 > 50. + assertTrue(store.hasCanonical(n100.id)) + + // Stronger vanish at ts=200 cascades it. + store.insert(vanish(ts = 200)) + assertFalse(store.hasCanonical(n100.id)) + + // And new events at ts=150 are now blocked. + val mid = note("mid", 150) + store.insert(mid) + assertFalse(store.hasCanonical(mid.id)) + } + + @Test + fun `earlier vanish does not lower a stronger cutoff`() { + store.insert(vanish(ts = 200)) + store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone + + val mid = note("mid", 150) + store.insert(mid) + assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200") + } + + // ------------------------------------------------------------------ + // Tombstone is a hardlink to the kind-62 event + // ------------------------------------------------------------------ + + @Test + fun `vanish tombstone shares an inode with the kind-62 event`() { + val v = vanish(ts = 30) + store.insert(v) + + val tombDir = root.resolve("tombstones/vanish") + val entries = Files.list(tombDir).use { it.toList() } + assertEquals(1, entries.size) + + val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json") + val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() + val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() + assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical") + } + + // ------------------------------------------------------------------ + // Vanish event itself remains queryable + // ------------------------------------------------------------------ + + @Test + fun `vanish event itself is indexed and queryable`() { + val v = vanish(ts = 30) + store.insert(v) + + val byKind = store.query(Filter(kinds = listOf(RequestToVanishEvent.KIND))) + assertEquals(listOf(v.id), byKind.map { it.id }) + } + + private fun FsEventStore.hasCanonical(id: String): Boolean { + val p = + root + .resolve("events") + .resolve(id.substring(0, 2)) + .resolve(id.substring(2, 4)) + .resolve("$id.json") + return p.exists() + } +}