From c4f07a96e3b649f2c05a629e6ea329bf8947e80f Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Tue, 6 Oct 2026 16:50:52 +0300 Subject: [PATCH 1/3] perf(updater): hash a full download while writing it `downloadFully` wrote the artifact, then `ChecksumVerifier.verify` read the whole file back to compute its SHA-512, and a mismatch read it a third time to build the exception. Hash the bytes as they are written through a `DigestOutputStream`, as `DifferentialDownloader` already does, and reuse that value for the comparison and for `ChecksumException`. The comparison, the rejected file's deletion and the exception are unchanged. --- .../dev/nucleusframework/updater/NucleusUpdater.kt | 10 ++++++---- .../updater/internal/ChecksumVerifier.kt | 10 ++++++++-- .../nucleusframework/updater/ChecksumVerifierTest.kt | 12 ++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt index 8640807f1..d6ae6585d 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.withContext import java.io.File import java.net.http.HttpClient import java.nio.file.Files +import java.security.DigestOutputStream import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Level import java.util.logging.Logger @@ -268,8 +269,9 @@ public class NucleusUpdater( val totalBytes = targetFile.size var bytesDownloaded = 0L + val digest = ChecksumVerifier.newSha512Digest() fetcher.open(targetFile.url).use { inputStream -> - tempFile.outputStream().use { outputStream -> + DigestOutputStream(tempFile.outputStream(), digest).use { outputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (inputStream.read(buffer).also { bytesRead = it } != -1) { @@ -280,9 +282,9 @@ public class NucleusUpdater( } } - // Verify checksum - if (!ChecksumVerifier.verify(tempFile, targetFile.sha512)) { - val actual = ChecksumVerifier.computeSha512Base64(tempFile) + // Verify checksum (hashed while writing, so the artifact is not read back) + val actual = ChecksumVerifier.encode(digest) + if (actual != targetFile.sha512) { tempFile.delete() throw ChecksumException(targetFile.sha512, actual) } diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt index 3be2ec659..00ab11abe 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt @@ -16,7 +16,7 @@ internal object ChecksumVerifier { private const val BUFFER_SIZE = 8192 fun computeSha512Base64(file: File): String { - val digest = MessageDigest.getInstance("SHA-512") + val digest = newSha512Digest() file.inputStream().buffered().use { input -> val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int @@ -24,6 +24,12 @@ internal object ChecksumVerifier { digest.update(buffer, 0, bytesRead) } } - return Base64.getEncoder().encodeToString(digest.digest()) + return encode(digest) } + + /** A fresh SHA-512 digest, to hash a download while it is written instead of reading it back. */ + fun newSha512Digest(): MessageDigest = MessageDigest.getInstance("SHA-512") + + /** Completes [digest] in the Base64 form the update manifest uses. */ + fun encode(digest: MessageDigest): String = Base64.getEncoder().encodeToString(digest.digest()) } diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt index eae8cf3f2..3929d2d84 100644 --- a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt @@ -1,12 +1,14 @@ package dev.nucleusframework.updater import dev.nucleusframework.updater.internal.ChecksumVerifier +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import java.security.DigestOutputStream import java.security.MessageDigest import java.util.Base64 @@ -75,4 +77,14 @@ class ChecksumVerifierTest { assertTrue(ChecksumVerifier.verify(file, expectedHash)) } + + @Test + fun `hashing while writing matches hashing the written file`() { + val file = tempFolder.newFile("test.bin") + val content = ByteArray(3 * 8192 + 17) { it.toByte() } + val digest = ChecksumVerifier.newSha512Digest() + DigestOutputStream(file.outputStream(), digest).use { it.write(content) } + + assertEquals(ChecksumVerifier.computeSha512Base64(file), ChecksumVerifier.encode(digest)) + } } From ca7e859c544673dd9ecdd50390bbdec0da0c1f1d Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Tue, 6 Oct 2026 20:09:31 +0300 Subject: [PATCH 2/3] refactor(updater): drop unused ChecksumVerifier helpers, share the digest with DifferentialDownloader --- .../updater/internal/ChecksumVerifier.kt | 23 ----- .../internal/delta/DifferentialDownloader.kt | 7 +- .../updater/ChecksumVerifierTest.kt | 90 ------------------- .../delta/DifferentialUpdateE2ETest.kt | 28 ++++++ 4 files changed, 31 insertions(+), 117 deletions(-) delete mode 100644 updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt index 00ab11abe..a66f16233 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/ChecksumVerifier.kt @@ -1,32 +1,9 @@ package dev.nucleusframework.updater.internal -import java.io.File import java.security.MessageDigest import java.util.Base64 internal object ChecksumVerifier { - fun verify( - file: File, - expectedSha512Base64: String, - ): Boolean { - val actual = computeSha512Base64(file) - return actual == expectedSha512Base64 - } - - private const val BUFFER_SIZE = 8192 - - fun computeSha512Base64(file: File): String { - val digest = newSha512Digest() - file.inputStream().buffered().use { input -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - while (input.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - return encode(digest) - } - /** A fresh SHA-512 digest, to hash a download while it is written instead of reading it back. */ fun newSha512Digest(): MessageDigest = MessageDigest.getInstance("SHA-512") diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/delta/DifferentialDownloader.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/delta/DifferentialDownloader.kt index e1f5f7fa9..13efc2bbf 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/delta/DifferentialDownloader.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/delta/DifferentialDownloader.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.updater.internal.delta +import dev.nucleusframework.updater.internal.ChecksumVerifier import kotlinx.coroutines.delay import java.io.File import java.io.IOException @@ -11,7 +12,6 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.security.DigestOutputStream import java.security.MessageDigest -import java.util.Base64 /** Everything needed to assemble one artifact from an older copy of it plus ranged requests. */ internal class DeltaDownload( @@ -52,7 +52,7 @@ internal class DifferentialDownloader( onProgress: suspend (Long, Long) -> Unit, ): Long { verifyPlanCoversArtifact(request) - val digest = MessageDigest.getInstance(SHA_512) + val digest = ChecksumVerifier.newSha512Digest() val transferred = try { assemble(request, digest, onProgress) @@ -94,7 +94,7 @@ internal class DifferentialDownloader( request: DeltaDownload, digest: MessageDigest, ) { - val actual = Base64.getEncoder().encodeToString(digest.digest()) + val actual = ChecksumVerifier.encode(digest) if (actual != request.expectedSha512 || request.target.length() != request.expectedSize) { request.target.delete() throw DeltaUnavailableException("Assembled artifact does not match the manifest") @@ -210,7 +210,6 @@ internal class DifferentialDownloader( } private companion object { - const val SHA_512 = "SHA-512" const val HTTP_PARTIAL_CONTENT = 206 const val BUFFER_SIZE = 64 * 1024 const val RANGE_REQUEST_PAUSE_EVERY = 100 diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt deleted file mode 100644 index 3929d2d84..000000000 --- a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/ChecksumVerifierTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package dev.nucleusframework.updater - -import dev.nucleusframework.updater.internal.ChecksumVerifier -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.security.DigestOutputStream -import java.security.MessageDigest -import java.util.Base64 - -class ChecksumVerifierTest { - @get:Rule - val tempFolder = TemporaryFolder() - - @Test - fun `verify correct checksum`() { - val file = tempFolder.newFile("test.bin") - val content = "Hello, World!".toByteArray() - file.writeBytes(content) - - val expectedHash = - Base64.getEncoder().encodeToString( - MessageDigest.getInstance("SHA-512").digest(content), - ) - - assertTrue(ChecksumVerifier.verify(file, expectedHash)) - } - - @Test - fun `verify incorrect checksum`() { - val file = tempFolder.newFile("test.bin") - file.writeBytes("Hello, World!".toByteArray()) - - assertFalse(ChecksumVerifier.verify(file, "wronghash")) - } - - @Test - fun `compute sha512 returns base64 string`() { - val file = tempFolder.newFile("test.bin") - file.writeBytes("test content".toByteArray()) - - val result = ChecksumVerifier.computeSha512Base64(file) - assertNotNull(result) - assertTrue(result.isNotEmpty()) - - // Verify it's valid base64 - val decoded = Base64.getDecoder().decode(result) - assertTrue(decoded.size == 64) // SHA-512 produces 64 bytes - } - - @Test - fun `verify empty file`() { - val file = tempFolder.newFile("empty.bin") - - val expectedHash = - Base64.getEncoder().encodeToString( - MessageDigest.getInstance("SHA-512").digest(ByteArray(0)), - ) - - assertTrue(ChecksumVerifier.verify(file, expectedHash)) - } - - @Test - fun `verify large file`() { - val file = tempFolder.newFile("large.bin") - val content = ByteArray(1_000_000) { (it % 256).toByte() } - file.writeBytes(content) - - val expectedHash = - Base64.getEncoder().encodeToString( - MessageDigest.getInstance("SHA-512").digest(content), - ) - - assertTrue(ChecksumVerifier.verify(file, expectedHash)) - } - - @Test - fun `hashing while writing matches hashing the written file`() { - val file = tempFolder.newFile("test.bin") - val content = ByteArray(3 * 8192 + 17) { it.toByte() } - val digest = ChecksumVerifier.newSha512Digest() - DigestOutputStream(file.outputStream(), digest).use { it.write(content) } - - assertEquals(ChecksumVerifier.computeSha512Base64(file), ChecksumVerifier.encode(digest)) - } -} diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt index de1a183e6..be4eb02a3 100644 --- a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt @@ -192,6 +192,28 @@ class DifferentialUpdateE2ETest { ) } + @Test + fun `a full download that does not match the manifest checksum is rejected and not staged`() { + val wrongSha512 = DeltaFixtures.V1_SHA512 + publish(version = "2.0.0", fileName = "MyApp-2.0.0.zip", artifact = DeltaFixtures.v2(), sha512 = wrongSha512) + val updater = updater(currentVersion = "1.0.0", differential = false) + val info = (runBlocking { updater.checkForUpdates() } as UpdateResult.Available).info + val stagingBefore = stagingDirs() + + val error = + assertThrows(ChecksumException::class.java) { + runBlocking { updater.downloadUpdate(info).toList() } + } + + assertEquals( + "the actual hash must be the SHA-512 of the bytes received", + "SHA-512 mismatch: expected=$wrongSha512, actual=${DeltaFixtures.sha512Base64(DeltaFixtures.v2())}", + error.message, + ) + assertEquals("no staging directory may be left behind", stagingBefore, stagingDirs()) + assertFalse("the rejected artifact must not be cached", UpdateCache(cacheDir).artifact.isFile) + } + @Test fun `progress is reported over the bytes actually transferred`() { primeCache(fileName = "MyApp-1.0.0.zip", artifact = DeltaFixtures.v1()) @@ -284,6 +306,12 @@ class DifferentialUpdateE2ETest { differential: Boolean = true, ): DownloadProgress = downloadAll(currentVersion, format, differential).last() + private fun stagingDirs(): Set = + File(System.getProperty("java.io.tmpdir")) + .list { _, name -> name.startsWith("nucleus-update-") } + .orEmpty() + .toSet() + private fun assertArtifactIs( expected: ByteArray, progress: DownloadProgress, From bbb432aa5336ef536e504c50528745bffdabb3c2 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Tue, 6 Oct 2026 20:37:55 +0300 Subject: [PATCH 3/3] test(updater): keep differential downloads on so the cache assertion can fail --- .../nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt index be4eb02a3..33d22b8c5 100644 --- a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialUpdateE2ETest.kt @@ -196,7 +196,7 @@ class DifferentialUpdateE2ETest { fun `a full download that does not match the manifest checksum is rejected and not staged`() { val wrongSha512 = DeltaFixtures.V1_SHA512 publish(version = "2.0.0", fileName = "MyApp-2.0.0.zip", artifact = DeltaFixtures.v2(), sha512 = wrongSha512) - val updater = updater(currentVersion = "1.0.0", differential = false) + val updater = updater(currentVersion = "1.0.0") val info = (runBlocking { updater.checkForUpdates() } as UpdateResult.Available).info val stagingBefore = stagingDirs()