Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,12 @@
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
}
/** 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")

private const val BUFFER_SIZE = 8192

fun computeSha512Base64(file: File): String {
val digest = MessageDigest.getInstance("SHA-512")
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 Base64.getEncoder().encodeToString(digest.digest())
}
/** Completes [digest] in the Base64 form the update manifest uses. */
fun encode(digest: MessageDigest): String = Base64.getEncoder().encodeToString(digest.digest())
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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")
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())
Expand Down Expand Up @@ -284,6 +306,12 @@ class DifferentialUpdateE2ETest {
differential: Boolean = true,
): DownloadProgress = downloadAll(currentVersion, format, differential).last()

private fun stagingDirs(): Set<String> =
File(System.getProperty("java.io.tmpdir"))
.list { _, name -> name.startsWith("nucleus-update-") }
.orEmpty()
.toSet()

private fun assertArtifactIs(
expected: ByteArray,
progress: DownloadProgress,
Expand Down
Loading