package link.roadio.riff.userstate import link.roadio.riff.storage.hash.nativeXxh3 import link.roadio.riff.storage.publish.AndroidDirectorySync import link.roadio.riff.storage.publish.DirectorySync import link.roadio.riff.userstate.model.FileHeaderModel import link.roadio.riff.userstate.model.FileRefModel import link.roadio.riff.userstate.model.ProfileStateModel import link.roadio.riff.userstate.model.StateManifestModel import link.roadio.riff.userstate.model.StatePointerModel import link.roadio.riff.userstate.model.UserStateCodec import java.io.File import java.io.FileOutputStream import java.nio.file.Files import java.nio.file.LinkOption.NOFOLLOW_LINKS import java.nio.file.NoSuchFileException import java.nio.file.StandardCopyOption.ATOMIC_MOVE import java.nio.file.StandardCopyOption.REPLACE_EXISTING import java.nio.file.attribute.BasicFileAttributes /** Independent logical streams, one atomic acceptance boundary. * Authority: https://wiki.271.pics/Riff/1-1-0/publication * AI-INTENT: PREVIOUS/CURRENT alone authorize recovery; directory enumeration only collects garbage. */ class UserSnapshotStore( private val directory: File, private val rootId: Long, private val hash: (ByteArray) -> Long = ::nativeXxh3, private val sync: DirectorySync = AndroidDirectorySync, private val checkpoint: (String) -> Unit = {}, private val profileValidation: (ProfileStateModel) -> Unit = {}, ) { data class Key( val kind: Int, val slot: Int, ) : Comparable { init { require(kind in 0..2 && if (kind == 0) slot == 0 else slot in 1..3) } override fun compareTo(other: Key): Int = compareValuesBy(this, other, Key::kind, Key::slot) } data class Snapshot( val manifest: StateManifestModel, val documents: Map, ) private val objects = File(directory, "objects") private val manifests = File(directory, "manifests") private var recoveredFromPrevious = false @Synchronized fun read(): Snapshot? { val current = File(directory, "CURRENT") val previous = File(directory, "PREVIOUS") if (!present(current) && !present(previous)) return null val first = validation { readPointer(current) } first.getOrNull()?.let { recoveredFromPrevious = false return it } return validation { readPointer(previous) }.onSuccess { recoveredFromPrevious = true }.getOrElse { error -> throw IllegalStateException("No valid user-state CURRENT or PREVIOUS", first.exceptionOrNull() ?: error) } } @Synchronized fun publish(replacements: Map): Snapshot { val prior = read() require(replacements.isNotEmpty()) val documents = prior?.documents.orEmpty() + replacements.mapValues { it.value.copyOf() } require(documents.keys == allKeys()) documents.forEach { (key, bytes) -> validateDocument(key, bytes) } replacements.forEach { (key, bytes) -> val generation = header(key, bytes).generation val old = prior?.documents?.get(key)?.let { header(key, it).generation } require(if (old == null) generation == 0L else generation == Math.addExact(old, 1L)) } UserStateValidation.transition(prior?.documents, documents, replacements.keys) if (recoveredFromPrevious) preserveRecoveryEvidence() require(directory.isDirectory || directory.mkdirs()) require(objects.isDirectory || objects.mkdir()) require(manifests.isDirectory || manifests.mkdir()) sync.sync(directory.toPath()) val refs = documents.toSortedMap().map { (key, bytes) -> val id = hash(bytes) immutable(File(objects, "${id.contentHex()}.fb"), bytes) checkpoint("object") FileRefModel(key.kind, key.slot, header(key, bytes).generation, id, bytes.size.toLong(), id.contentHex()) } val manifest = StateManifestModel(1, rootId, refs) val manifestBytes = UserStateCodec.encode(manifest) val manifestId = hash(manifestBytes) immutable(File(manifests, "${manifestId.contentHex()}.fb"), manifestBytes) checkpoint("manifest") val pointer = UserStateCodec.encode(StatePointerModel(1, manifestId, manifestBytes.size.toLong(), manifestId.contentHex())) // The prior snapshot may be PREVIOUS after corruption. Never preserve invalid CURRENT bytes. if (prior != null) { val oldBytes = UserStateCodec.encode(prior.manifest) val oldId = hash(oldBytes) immutable(File(manifests, "${oldId.contentHex()}.fb"), oldBytes) replace( File(directory, "PREVIOUS"), UserStateCodec.encode(StatePointerModel(1, oldId, oldBytes.size.toLong(), oldId.contentHex())), ) } checkpoint("previous") replace(File(directory, "CURRENT"), pointer) checkpoint("current") val accepted = readPointer(File(directory, "CURRENT")) runCatching(::collectGarbage) return accepted } @Synchronized fun openCurrent(open: (File) -> T): Map { val snapshot = checkNotNull(read()) val opened = linkedMapOf() try { snapshot.manifest.files.forEach { ref -> opened[Key(ref.kind, ref.profileSlot)] = open(File(objects, "${checkNotNull(ref.objectId).contentHex()}.fb")) } return opened } catch (error: Throwable) { opened.values.forEach { runCatching { it.close() } } throw error } } private fun readPointer(file: File): Snapshot { val pointer = UserStateCodec.decodeStatePointer(bounded(file, 4096)) require(pointer.formatVersion == 1) val id = checkNotNull(pointer.manifestId) require(pointer.xxh364 == id.contentHex()) val manifestBytes = bounded(File(manifests, "${id.contentHex()}.fb"), 64 * 1024) require(manifestBytes.size.toLong() == pointer.byteLength && hash(manifestBytes) == id) val manifest = UserStateCodec.decodeStateManifest(manifestBytes) require(manifest.formatVersion == 1 && manifest.rootId == rootId) require(manifest.files.size == 7) val documents = linkedMapOf() manifest.files.forEach { ref -> val key = Key(ref.kind, ref.profileSlot) require(key !in documents) val objectId = checkNotNull(ref.objectId) require(ref.xxh364 == objectId.contentHex()) val bytes = bounded(File(objects, "${objectId.contentHex()}.fb")) require(ref.byteLength == bytes.size.toLong() && hash(bytes) == objectId) validateDocument(key, bytes) require(header(key, bytes).generation == ref.generation) documents[key] = bytes } require(documents.keys == allKeys()) return Snapshot(manifest, documents) } private fun validateDocument( key: Key, bytes: ByteArray, ) { validate(key, bytes, ContentIds(hash)) if (key.kind == 1) profileValidation(UserStateCodec.decodeProfileState(bytes)) } private fun immutable( file: File, bytes: ByteArray, ) { if (present(file)) { val stored = bounded(file) require(stored.size == bytes.size && hash(stored) == hash(bytes)) { "Invalid immutable object integrity" } return } val temporary = File(file.parentFile, file.name + ".pending") force(temporary, bytes) checkpoint("forced") Files.move(temporary.toPath(), file.toPath(), ATOMIC_MOVE) sync.sync(checkNotNull(file.parentFile).toPath()) } private fun replace( file: File, bytes: ByteArray, ) { val temporary = File(file.parentFile, file.name + ".pending") force(temporary, bytes) Files.move(temporary.toPath(), file.toPath(), ATOMIC_MOVE, REPLACE_EXISTING) sync.sync(checkNotNull(file.parentFile).toPath()) } private fun force( file: File, bytes: ByteArray, ) { FileOutputStream(file).use { out -> out.write(bytes) out.fd.sync() } } private fun bounded( file: File, limit: Int = 64 * 1024 * 1024, ): ByteArray { val attrs = try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) } catch ( error: NoSuchFileException, ) { throw IllegalArgumentException("Missing user-state member", error) } require(attrs.isRegularFile && attrs.size() in 8..limit.toLong()) { "Invalid user-state object length" } return file.inputStream().use { input -> val out = java.io.ByteArrayOutputStream() val block = ByteArray(8192) while (true) { val count = input.read(block) if (count < 0) break require(out.size() <= limit - count) out.write(block, 0, count) } out.toByteArray() } } private fun preserveRecoveryEvidence() { fun raw(file: File): ByteArray { val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) require(attrs.isRegularFile && attrs.size() <= 64L * 1024 * 1024) return file.readBytes() } val pointers = listOf("CURRENT", "PREVIOUS").associateWith { name -> File(directory, name).takeIf(::present)?.let(::raw) } val id = ContentIds(hash).id("user-state-recovery-evidence") { pointers.forEach { (name, bytes) -> text(name) bool(bytes != null) bytes?.let { int(it.size) raw(it) } } } val quarantine = File(directory, "quarantine") val archive = File(quarantine, id.contentHex()) require(archive.isDirectory || archive.mkdirs()) val sources = listOf(directory to setOf("CURRENT", "PREVIOUS"), objects to null, manifests to null) sources.forEach { (folder, selected) -> val destination = if (folder == directory) archive else File(archive, folder.name) require(destination.isDirectory || destination.mkdir()) folder .listFiles() .orEmpty() .filter { if (selected != null ) { it.name in selected } else { Regex("[0-9a-f]{16}\\.fb").matches(it.name) } }.forEach { source -> val bytes = raw(source) val target = File(destination, source.name) if (present(target)) { require( raw(target).let { it.size == bytes.size && hash(it) == hash(bytes) }, ) { "Invalid recovery evidence integrity" } } else { replace(target, bytes) } } sync.sync(destination.toPath()) } sync.sync(archive.toPath()) sync.sync(quarantine.toPath()) sync.sync(directory.toPath()) recoveredFromPrevious = false } private fun collectGarbage() { val retained = listOfNotNull( runCatching { readPointer(File(directory, "CURRENT")) }.getOrNull(), runCatching { readPointer(File(directory, "PREVIOUS")) }.getOrNull(), ) val liveObjects = retained.flatMap { it.manifest.files }.map { "${checkNotNull(it.objectId).contentHex()}.fb" }.toSet() val liveManifests = retained.map { "${hash(UserStateCodec.encode(it.manifest)).contentHex()}.fb" }.toSet() for ((folder, live) in listOf(objects to liveObjects, manifests to liveManifests)) { folder .listFiles() ?.filter { !Files.isSymbolicLink(it.toPath()) && it.isFile && Regex("[0-9a-f]{16}\\.fb(?:\\.pending)?").matches(it.name) && it.name !in live }?.forEach { Files.delete(it.toPath()) } sync.sync(folder.toPath()) } } companion object { fun existingRootId( directory: File, hash: (ByteArray) -> Long = ::nativeXxh3, ): Long? { val pointers = listOf(File(directory, "CURRENT"), File(directory, "PREVIOUS")) if (pointers.none { present(it) }) return null for (file in pointers) { val id = validation { val pointer = UserStateCodec.decodeStatePointer(limited(file, 4096)) require(pointer.formatVersion == 1 && pointer.xxh364 == checkNotNull(pointer.manifestId).contentHex()) val manifest = File(directory, "manifests/${checkNotNull(pointer.manifestId).contentHex()}.fb") val bytes = limited(manifest, 65536) require(bytes.size.toLong() == pointer.byteLength) require(hash(bytes) == pointer.manifestId) checkNotNull(UserStateCodec.decodeStateManifest(bytes).rootId) }.getOrNull() if (id != null) return id } error("No valid user-state root descriptor") } private fun limited( file: File, limit: Int, ): ByteArray { val attrs = try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) } catch ( error: NoSuchFileException, ) { throw IllegalArgumentException("Missing user-state descriptor", error) } require(attrs.isRegularFile && attrs.size() in 8..limit.toLong()) return file.readBytes().also { require(it.size in 8..limit) } } private fun present(file: File): Boolean = try { Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, NOFOLLOW_LINKS) true } catch (_: NoSuchFileException) { false } private fun validation(action: () -> T): Result = try { Result.success(action()) } catch (error: java.io.IOException) { throw error } catch (error: IllegalArgumentException) { Result.failure(error) } catch (error: IllegalStateException) { Result.failure(error) } catch (error: IndexOutOfBoundsException) { Result.failure(error) } fun allKeys(): Set = setOf(Key(0, 0)) + (1..3).flatMap { listOf(Key(1, it), Key(2, it)) } fun header( key: Key, bytes: ByteArray, ): FileHeaderModel = when (key.kind) { 0 -> UserStateCodec.decodeInstallationState(bytes).header 1 -> UserStateCodec.decodeProfileState(bytes).header else -> UserStateCodec.decodeListeningHistory(bytes).header } fun validate( key: Key, bytes: ByteArray, ids: ContentIds? = null, ) { require(header(key, bytes).profileSlot == key.slot) when (key.kind) { 0 -> { UserStateValidation.installation(UserStateCodec.decodeInstallationState(bytes)) } 1 -> { UserStateValidation.profile(UserStateCodec.decodeProfileState(bytes), ids) } else -> { require(bytes.size <= UserStateValidation.HISTORY_BYTES && bytes.size % UserStateValidation.ALIGNMENT == 0) val used = UserStateWireVerifier.verify(bytes, "ListeningHistory") require((used until bytes.size).all { bytes[it] == 0.toByte() }) { "Invalid history padding" } UserStateValidation.history(UserStateCodec.decodeListeningHistory(bytes)) } } } } }