[download] verify already downloaded files

This commit is contained in:
Torsten Grote
2025-10-14 15:09:29 -03:00
parent 1cb34b1e63
commit 4579b82a4c
2 changed files with 83 additions and 1 deletions

View File

@@ -25,9 +25,12 @@ import io.ktor.client.plugins.ResponseException
import io.ktor.http.HttpStatusCode.Companion.NotFound
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import org.fdroid.fdroid.toHex
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
/**
* Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc.
@@ -62,10 +65,23 @@ public class HttpDownloaderV2 constructor(
var resumable = false
val fileLength = outputFile.length()
if (fileLength > (request.indexFile.size ?: -1)) {
// file was larger than expected, so delete and re-download
if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" }
} else if (fileLength == request.indexFile.size && outputFile.isFile) {
log.debug { "Already have outputFile, not downloading: ${outputFile.name}" }
return // already have it!
if (request.indexFile.sha256 == null) {
// no way to check file, so we trust that what we have is legit (v1 only)
return
} else {
if (hashFile(outputFile) == request.indexFile.sha256) {
// hash matched, so we already have the good file, don't download again
return
} else {
log.warn { "Hash mismatch for ${request.indexFile}" }
// delete file and continue
if (!outputFile.delete()) log.warn { "Warning: outputFile not deleted" }
}
}
} else if (fileLength > 0) {
resumable = true
}
@@ -90,4 +106,21 @@ public class HttpDownloaderV2 constructor(
override fun close() {
}
private fun hashFile(file: File): String {
val messageDigest: MessageDigest = try {
MessageDigest.getInstance("SHA-256")
} catch (e: NoSuchAlgorithmException) {
throw AssertionError(e)
}
file.inputStream().use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
return messageDigest.digest().toHex()
}
}

View File

@@ -25,6 +25,7 @@ import org.fdroid.runSuspend
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import java.io.File
import java.io.IOException
import java.net.BindException
import java.net.ServerSocket
@@ -33,6 +34,7 @@ import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlin.test.fail
@@ -214,6 +216,53 @@ internal class HttpDownloaderTest {
assertContentEquals(firstBytes + secondBytes, file.readBytes())
}
/**
* Tests re-using an already downloaded file with hash verification.
* This can fail if the hashing doesn't take the already downloaded bytes into account.
*/
@Test
fun testCompleteResumeWithHashSuccess() = runSuspend {
val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368"
val file = File(folder.newFolder(), sha256).apply { createNewFile() }
val bytes = ("These are the first bytes that were already downloaded." +
"These are the last bytes that still need to be downloaded.").encodeToByteArray()
file.writeBytes(bytes)
// specifying the sha256 hash forces its validation
val indexFile = getIndexFile("foo/bar", sha256, bytes.size.toLong())
val downloadRequest = DownloadRequest(indexFile, mirrors)
val httpManager = HttpManager(userAgent, null)
val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file)
// this throws if the hash doesn't match while downloading
httpDownloader.download()
assertContentEquals(bytes, file.readBytes())
}
@Test
fun testCompleteResumeWithHashFailure() = runSuspend {
val sha256 = "efabb260da949061c88173c19f369b4aa0eaa82003c7c2dec08b5dfe75525368"
val file = File(folder.newFolder(), sha256).apply { createNewFile() }
val bytes = ("These are the first bytes that were already downloaded." +
"These are the last bytes that still need to be downloaded.").encodeToByteArray()
file.writeBytes(bytes)
// specifying the sha256 hash forces its validation
val indexFile = getIndexFile("foo/bar", sha256.replaceFirst('e', 'f'), bytes.size.toLong())
val downloadRequest = DownloadRequest(indexFile, mirrors)
val mockEngine = MockEngine.config {
reuseHandlers = false
addHandler {
respond("", OK)
}
}
val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = mockEngine)
val httpDownloader = HttpDownloaderV2(httpManager, downloadRequest, file)
val e = assertFailsWith<IOException> {
httpDownloader.download()
}
assertEquals("Hash not matching", e.message)
}
@Test
fun testResumeError() = runSuspend {
val file = folder.newFile()